Skip to main content

rlx_ocr/model/
detection.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-detection U-Net ([`ocrs_models::DetectionModel`](https://github.com/robertknight/ocrs-models)).
17
18use super::weights::{
19    DET_DW_KEYS, DET_ONNX_PW, OcrGraphBuilder, assert_weights_drained, detection_input_hw,
20};
21use anyhow::Result;
22use rlx_core::vision_ops_ir::{
23    conv_transpose2d_k3s2_bias_trim, conv2d_bias, conv2d_bias_groups, max_pool2d_2x2, sigmoid_nchw,
24};
25use rlx_core::weight_map::WeightMap;
26use rlx_ir::hir::HirNodeId;
27use rlx_ir::{DType, HirGraphExt, Shape};
28
29/// Fixed compile-time input size for the HF detection checkpoint (override via `OCR_DETECTION_HW`).
30#[allow(dead_code)]
31pub const DEFAULT_DETECTION_INPUT_HW: (usize, usize) = (800, 600);
32
33#[derive(Clone, Copy, Debug)]
34pub struct DetectionGraphConfig {
35    pub batch: usize,
36    pub height: usize,
37    pub width: usize,
38}
39
40impl Default for DetectionGraphConfig {
41    fn default() -> Self {
42        let (height, width) = detection_input_hw();
43        Self {
44            batch: 1,
45            height,
46            width,
47        }
48    }
49}
50
51/// Channel widths at each U-Net level (matches `ocrs_models`).
52const DEPTH_SCALE: [usize; 7] = [8, 16, 32, 32, 64, 128, 256];
53
54pub fn build_detection_graph(
55    wm: &mut WeightMap,
56    cfg: DetectionGraphConfig,
57) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
58    build_detection_graph_to_stage(wm, cfg, None)
59}
60
61/// Build the detection graph, optionally stopping early and emitting the
62/// intermediate feature map. Stage numbering: `0` = after in_conv, `1..=6` =
63/// after each encoder down-level, `7..=12` = after each decoder up-level,
64/// `None` = full (logits → sigmoid mask). Used by the Metal-vs-CPU bisect.
65pub fn build_detection_graph_to_stage(
66    wm: &mut WeightMap,
67    cfg: DetectionGraphConfig,
68    stop: Option<u8>,
69) -> Result<(rlx_ir::Graph, std::collections::HashMap<String, Vec<f32>>)> {
70    let mut b = OcrGraphBuilder::new("ocr_detection");
71    let f = DType::F32;
72    let batch = cfg.batch;
73    let mut h = cfg.height;
74    let mut w = cfg.width;
75
76    let image = b.m().input("image", Shape::new(&[batch, 1, h, w], f));
77
78    // Sub-op bisection of the in_conv (stages 50..=53: after dw0/pw0/dw1/pw1).
79    if matches!(stop, Some(50..=53)) {
80        let s = stop.unwrap();
81        let c0 = DEPTH_SCALE[0];
82        let mut x = depthwise_conv(&mut b, wm, image, DET_DW_KEYS[0], 1, batch, h, w)?;
83        if s == 50 {
84            b.m().set_outputs(vec![x]);
85            return b.finish();
86        }
87        let (pw0_w, pw0_b) = DET_ONNX_PW[0];
88        x = pointwise_relu(&mut b, wm, x, pw0_w, pw0_b, 1, c0, batch, h, w)?;
89        if s == 51 {
90            b.m().set_outputs(vec![x]);
91            return b.finish();
92        }
93        x = depthwise_conv(&mut b, wm, x, DET_DW_KEYS[1], c0, batch, h, w)?;
94        if s == 52 {
95            b.m().set_outputs(vec![x]);
96            return b.finish();
97        }
98        let (pw1_w, pw1_b) = DET_ONNX_PW[1];
99        x = pointwise_relu(&mut b, wm, x, pw1_w, pw1_b, c0, c0, batch, h, w)?;
100        b.m().set_outputs(vec![x]);
101        return b.finish();
102    }
103
104    let mut block = 0usize;
105    let mut x = double_conv(
106        &mut b,
107        wm,
108        image,
109        &mut block,
110        1,
111        DEPTH_SCALE[0],
112        batch,
113        h,
114        w,
115    )?;
116    if stop == Some(0) {
117        b.m().set_outputs(vec![x]);
118        return b.finish();
119    }
120    let in_conv_skip = (x, h, w);
121
122    let mut x_down: Vec<(HirNodeId, usize, usize)> = Vec::new();
123    for level in 0..DEPTH_SCALE.len() - 1 {
124        let in_c = DEPTH_SCALE[level];
125        let out_c = DEPTH_SCALE[level + 1];
126        x = double_conv(&mut b, wm, x, &mut block, in_c, out_c, batch, h, w)?;
127        x = max_pool2d_2x2(&mut b.m(), x, batch, out_c, h, w);
128        h /= 2;
129        w /= 2;
130        x_down.push((x, h, w));
131        if stop == Some(1 + level as u8) {
132            b.m().set_outputs(vec![x]);
133            return b.finish();
134        }
135    }
136
137    let mut x_up = x;
138    let mut up_h = h;
139    let mut up_w = w;
140    for (up_stage, up_idx) in (7u8..).zip((0..DEPTH_SCALE.len() - 1).rev()) {
141        let out_c = DEPTH_SCALE[up_idx];
142        let cross_c = DEPTH_SCALE[up_idx];
143        let (skip, skip_h, skip_w) = if up_idx == 0 {
144            (in_conv_skip.0, in_conv_skip.1, in_conv_skip.2)
145        } else {
146            let (skip_node, sh, sw) = x_down[up_idx - 1];
147            (skip_node, sh, sw)
148        };
149
150        let up_w_key = format!("up.{up_idx}.up.weight");
151        let up_b_key = format!("up.{up_idx}.up.bias");
152        let up_weight = b.load_param(wm, &up_w_key)?;
153        let up_bias = b.load_param(wm, &up_b_key)?;
154        let upscaled = conv_transpose2d_k3s2_bias_trim(
155            &mut b.m(),
156            x_up,
157            up_weight,
158            up_bias,
159            batch,
160            out_c,
161            up_h,
162            up_w,
163            skip_h,
164            skip_w,
165        );
166        up_h = skip_h;
167        up_w = skip_w;
168
169        let cat = b.m().concat_(vec![upscaled, skip], 1);
170        x_up = double_conv(
171            &mut b,
172            wm,
173            cat,
174            &mut block,
175            out_c + cross_c,
176            out_c,
177            batch,
178            up_h,
179            up_w,
180        )?;
181        if stop == Some(up_stage) {
182            b.m().set_outputs(vec![x_up]);
183            return b.finish();
184        }
185    }
186
187    let out_w = b.load_param(wm, "out_conv.0.weight")?;
188    let out_b = b.load_param(wm, "out_conv.0.bias")?;
189    let logits = conv2d_bias(
190        &mut b.m(),
191        x_up,
192        out_w,
193        out_b,
194        batch,
195        1,
196        1,
197        1,
198        [1, 1],
199        [0, 0],
200        up_h,
201        up_w,
202    );
203    if stop == Some(13) {
204        b.m().set_outputs(vec![logits]);
205        return b.finish();
206    }
207    let mask = sigmoid_nchw(&mut b.m(), logits);
208    b.m().set_outputs(vec![mask]);
209
210    if stop.is_none() {
211        assert_weights_drained(wm, "detection graph")?;
212    }
213    b.finish()
214}
215
216fn double_conv(
217    b: &mut OcrGraphBuilder,
218    wm: &mut WeightMap,
219    mut x: HirNodeId,
220    block: &mut usize,
221    in_c: usize,
222    out_c: usize,
223    batch: usize,
224    h: usize,
225    w: usize,
226) -> Result<HirNodeId> {
227    let (pw0_w, pw0_b) = DET_ONNX_PW[*block];
228    // `DepthwiseConv`: dw 3×3 → pw 1×1 (+ fused BN in onnx keys) → ReLU (once).
229    x = depthwise_conv(b, wm, x, DET_DW_KEYS[*block], in_c, batch, h, w)?;
230    x = pointwise_relu(b, wm, x, pw0_w, pw0_b, in_c, out_c, batch, h, w)?;
231    *block += 1;
232    let (pw1_w, pw1_b) = DET_ONNX_PW[*block];
233    x = depthwise_conv(b, wm, x, DET_DW_KEYS[*block], out_c, batch, h, w)?;
234    x = pointwise_relu(b, wm, x, pw1_w, pw1_b, out_c, out_c, batch, h, w)?;
235    *block += 1;
236    Ok(x)
237}
238
239fn depthwise_conv(
240    b: &mut OcrGraphBuilder,
241    wm: &mut WeightMap,
242    x: HirNodeId,
243    dw_key: &str,
244    channels: usize,
245    batch: usize,
246    h: usize,
247    w: usize,
248) -> Result<HirNodeId> {
249    let weight = b.load_param(wm, dw_key)?;
250    let bias = b.zero_bias(channels)?;
251    Ok(conv2d_bias_groups(
252        &mut b.m(),
253        x,
254        weight,
255        bias,
256        batch,
257        channels,
258        3,
259        3,
260        [1, 1],
261        [1, 1],
262        channels,
263        h,
264        w,
265    ))
266}
267
268fn pointwise_relu(
269    b: &mut OcrGraphBuilder,
270    wm: &mut WeightMap,
271    x: HirNodeId,
272    w_key: &str,
273    b_key: &str,
274    _in_c: usize,
275    out_c: usize,
276    batch: usize,
277    h: usize,
278    w: usize,
279) -> Result<HirNodeId> {
280    let weight = b.load_param(wm, w_key)?;
281    let bias = b.load_param(wm, b_key)?;
282    let y = conv2d_bias(
283        &mut b.m(),
284        x,
285        weight,
286        bias,
287        batch,
288        out_c,
289        1,
290        1,
291        [1, 1],
292        [0, 0],
293        h,
294        w,
295    );
296    Ok(b.m().relu(y))
297}