Skip to main content

ppocr_rs/
doc_unwarp.rs

1//! Document unwarping via UVDoc ONNX (PP-StructureV3 / PaddleX).
2//!
3//! UVDoc rettifica geometricamente immagini di documenti fotografati o
4//! scansionati con distorsioni prospettiche / di curvatura.
5//!
6//! ## Come funziona internamente (PaddleX 3.7)
7//!
8//! Il modello gestisce internamente la riduzione di risoluzione:
9//!
10//! ```text
11//! input (originale, qualsiasi risoluzione)
12//!   → interpolate a [712, 488] per il backbone
13//!   → head produce sampling grid a [712, 488]
14//!   → grid upsample a dimensioni originali
15//!   → grid_sample(input_originale, grid_hires)
16//!   → output a dimensioni originali, qualità piena
17//! ```
18//!
19//! **Implicazione**: passare l'immagine all'ONNX SENZA resize preventivo.
20//! L'ONNX ha shape dinamica e restituisce output alle stesse dimensioni
21//! dell'input, preservando tutta la risoluzione originale.
22//!
23//! ## Modello
24//!
25//! `PaddlePaddle/UVDoc_onnx` su HuggingFace (~30 MB).
26//! Download via `ModelHub::ensure_single(PpStructureModel::DocUnwarp)`.
27//!
28//! ## Formati output gestiti
29//!
30//! Il metodo [`DocUnwarper::unwarp`] rileva automaticamente il formato
31//! dell'output ONNX e applica la decodifica corretta:
32//!
33//! | Shape output      | Interpretazione                                        |
34//! |-------------------|--------------------------------------------------------|
35//! | `[1, 3, H, W]`    | Immagine rettificata diretta (float32 [0,1])           |
36//! | `[1, H, W, 2]`    | Grid campionamento normalizzato in `[-1, 1]`           |
37//! | `[1, H*W, 2]`     | Grid flat (H=W=sqrt(N))                                |
38//! | `[1, 2, H, W]`    | Campo flusso/offset (CHW layout)                       |
39//!
40//! ## Preprocessing
41//!
42//! Solo normalizzazione ImageNet ([0,1] → mean/std): **nessun resize**.
43//! Il modello si aspetta l'immagine originale a piena risoluzione.
44
45use crate::ocr_error::OcrError;
46use ndarray::{Array, Array4};
47use ort::{
48    inputs,
49    session::{builder::GraphOptimizationLevel, Session},
50    value::Tensor,
51};
52use std::path::Path;
53
54/// Risoluzione interna usata dal backbone UVDoc (PaddleX default).
55/// Usato solo come fallback informativo nei log — l'ONNX accetta shape
56/// dinamica e opera internamente a questa risoluzione indipendentemente
57/// dalla dimensione dell'input passato.
58const BACKBONE_INPUT_SIZE: u32 = 488;
59
60// ─── Output type ─────────────────────────────────────────────────────────────
61
62/// Immagine rettificata prodotta da UVDoc.
63pub struct UnwarpResult {
64    /// Immagine documento rettificata (stessa dimensione dell'input originale).
65    pub image: image::RgbImage,
66    /// True se il modello ha emesso l'immagine direttamente (senza grid sampling).
67    pub is_direct_output: bool,
68}
69
70// ─── DocUnwarper ─────────────────────────────────────────────────────────────
71
72/// Wrapper ONNX per UVDoc document unwarping.
73pub struct DocUnwarper {
74    session: Session,
75    /// Abilita la rettificazione. **Default `false`**: quando disabilitato
76    /// [`unwarp`] restituisce l'immagine originale invariata senza alcuna
77    /// inferenza. Impostare a `true` solo su documenti fotografati/distorti.
78    pub enabled: bool,
79}
80
81impl DocUnwarper {
82    /// Carica il modello da file.
83    ///
84    /// L'ONNX ha shape dinamica: accetta l'immagine a qualsiasi risoluzione e
85    /// restituisce output alle stesse dimensioni. Il backbone interno opera a
86    /// ~488×712 indipendentemente dalla risoluzione passata.
87    pub fn from_path(model_path: impl AsRef<Path>) -> Result<Self, OcrError> {
88        log_backbone_size();
89        let session = Session::builder()?
90            .with_optimization_level(GraphOptimizationLevel::Level3)?
91            .commit_from_file(model_path)?;
92        Ok(Self { session, enabled: false })
93    }
94
95    /// Costruttore da Session pre-caricata.
96    pub fn from_session(session: Session) -> Self {
97        log_backbone_size();
98        Self { session, enabled: false }
99    }
100
101    /// Rettifica un'immagine documento distorta.
102    ///
103    /// Se `self.enabled == false` (default) restituisce l'immagine originale
104    /// clonata senza eseguire alcuna inferenza.
105    ///
106    /// L'immagine di output ha **le stesse dimensioni dell'input originale**:
107    /// il modello riduce internamente a ~488×712 per l'estrazione features,
108    /// poi upsampling la griglia alle dimensioni originali e applica il
109    /// grid_sample sull'immagine originale ad alta risoluzione.
110    pub fn unwarp(&self, image: &image::RgbImage) -> Result<UnwarpResult, OcrError> {
111        if !self.enabled {
112            return Ok(UnwarpResult { image: image.clone(), is_direct_output: true });
113        }
114
115        let orig_w = image.width();
116        let orig_h = image.height();
117
118        // ── Preprocess: solo normalizzazione, nessun resize ─────────────────
119        // Il modello gestisce internamente la riduzione di risoluzione per il
120        // backbone e restituisce output alle dimensioni originali.
121        let blob = preprocess_normalize_only(image);
122        let input_name = self.session.inputs[0].name.clone();
123
124        let outputs = self.session.run(
125            inputs![input_name => Tensor::from_array(blob)?]?
126        )?;
127
128        let (_, first_out) = outputs.iter().next()
129            .ok_or_else(|| OcrError::ModelOutput("UVDoc: nessun output".into()))?;
130        let (shape, data) = crate::compat::tensor_extract_with_shape_f32(&first_out)?;
131
132        eprintln!("[UVDoc] input: {orig_w}×{orig_h}  output shape: {shape:?}");
133
134        // ── Interpreta output ───────────────────────────────────────────────
135        let result_img = match shape.as_slice() {
136            // Immagine rettificata diretta: [1, 3, H, W]
137            // H e W devono essere uguali all'input originale (pipeline PaddleX).
138            // Se per qualsiasi ragione non coincidono, resize di sicurezza.
139            [1, 3, out_h, out_w] => {
140                let img = float_chw_to_rgb(&data, *out_h as u32, *out_w as u32);
141                if *out_h as u32 == orig_h && *out_w as u32 == orig_w {
142                    return Ok(UnwarpResult { image: img, is_direct_output: true });
143                }
144                // fallback: output a dimensione diversa dall'input (ONNX non standard)
145                eprintln!(
146                    "[UVDoc] WARN: output {out_w}×{out_h} ≠ originale {orig_w}×{orig_h} \
147                     — upsampling con Lanczos3 (perdita qualità)"
148                );
149                let resized = image::imageops::resize(
150                    &img, orig_w, orig_h, image::imageops::FilterType::Lanczos3,
151                );
152                return Ok(UnwarpResult { image: resized, is_direct_output: true });
153            }
154
155            // Sampling grid [1, H, W, 2]: coordinate normalizzate in [-1, 1]
156            // Applicato sull'immagine originale → output a dimensioni originali.
157            [1, grid_h, grid_w, 2] => {
158                let grid: Vec<(f32, f32)> = (0..(*grid_h * *grid_w) as usize)
159                    .map(|i| (data[i * 2], data[i * 2 + 1]))
160                    .collect();
161                // Se la griglia è più piccola dell'originale, interpola prima
162                // di applicare (come fa PaddleX con F.interpolate sul bm).
163                if *grid_h as u32 != orig_h || *grid_w as u32 != orig_w {
164                    let grid_upsampled = upsample_grid(
165                        &grid, *grid_w as u32, *grid_h as u32, orig_w, orig_h,
166                    );
167                    grid_sample_to_rgb(image, &grid_upsampled, orig_w, orig_h)
168                } else {
169                    grid_sample_to_rgb(image, &grid, *grid_w as u32, *grid_h as u32)
170                }
171            }
172
173            // Grid flat [1, N, 2]: N = grid_h × grid_w
174            [1, n, 2] => {
175                let side = (*n as f32).sqrt().round() as u32;
176                let grid: Vec<(f32, f32)> = (0..*n as usize)
177                    .map(|i| (data[i * 2], data[i * 2 + 1]))
178                    .collect();
179                let grid_up = upsample_grid(&grid, side, side, orig_w, orig_h);
180                grid_sample_to_rgb(image, &grid_up, orig_w, orig_h)
181            }
182
183            // Flusso / offset [1, 2, H, W]: sommare alla meshgrid canonica
184            [1, 2, out_h, out_w] => {
185                let (oh, ow) = (*out_h as usize, *out_w as usize);
186                let mut grid: Vec<(f32, f32)> = Vec::with_capacity(oh * ow);
187                for y in 0..oh {
188                    for x in 0..ow {
189                        let cx = (x as f32 / (ow - 1) as f32) * 2.0 - 1.0 + data[x + y * ow];
190                        let cy = (y as f32 / (oh - 1) as f32) * 2.0 - 1.0 + data[oh * ow + x + y * ow];
191                        grid.push((cx, cy));
192                    }
193                }
194                let grid_up = upsample_grid(&grid, *out_w as u32, *out_h as u32, orig_w, orig_h);
195                grid_sample_to_rgb(image, &grid_up, orig_w, orig_h)
196            }
197
198            other => {
199                return Err(OcrError::ModelOutput(format!(
200                    "UVDoc: formato output non riconosciuto: {other:?}"
201                )));
202            }
203        };
204
205        Ok(UnwarpResult { image: result_img, is_direct_output: false })
206    }
207}
208
209// ─── Preprocessing ────────────────────────────────────────────────────────────
210
211/// Normalizza senza resize. PaddleX divide per 255, nessuna sottrazione mean/std.
212/// Il modello UVDoc (sia Paddle che ONNX) opera internamente su [0,1].
213fn preprocess_normalize_only(image: &image::RgbImage) -> Array4<f32> {
214    let h = image.height() as usize;
215    let w = image.width() as usize;
216    let mut blob: Array4<f32> = Array::zeros((1, 3, h, w));
217    for y in 0..h {
218        for x in 0..w {
219            let p = image.get_pixel(x as u32, y as u32);
220            for c in 0..3usize {
221                blob[[0, c, y, x]] = p[c] as f32 / 255.0;
222            }
223        }
224    }
225    blob
226}
227
228fn log_backbone_size() {
229    eprintln!(
230        "[UVDoc] backbone interno ~{BACKBONE_INPUT_SIZE}×712 — \
231         passare immagine originale senza resize per output ad alta risoluzione"
232    );
233}
234
235// ─── Output decoding ─────────────────────────────────────────────────────────
236
237/// Campiona `src` secondo la griglia di coordinate normalizzate `grid` usando
238/// interpolazione bilineare. Equivalente a `torch.nn.functional.grid_sample`
239/// con `mode="bilinear"`, `padding_mode="border"`, `align_corners=True`.
240///
241/// `grid[i]` = `(x_norm, y_norm)` ∈ `[-1, 1]` dove:
242/// - `(-1, -1)` = angolo top-left dell'immagine sorgente
243/// - `(+1, +1)` = angolo bottom-right
244fn grid_sample_to_rgb(
245    src:    &image::RgbImage,
246    grid:   &[(f32, f32)],
247    out_w:  u32,
248    out_h:  u32,
249) -> image::RgbImage {
250    let src_w = src.width() as f32;
251    let src_h = src.height() as f32;
252    let mut out = image::RgbImage::new(out_w, out_h);
253
254    for gy in 0..out_h as usize {
255        for gx in 0..out_w as usize {
256            let (xn, yn) = grid[gy * out_w as usize + gx];
257
258            // Da normalizzato [-1,1] a pixel floating-point
259            let sx = (xn + 1.0) * 0.5 * (src_w - 1.0);
260            let sy = (yn + 1.0) * 0.5 * (src_h - 1.0);
261
262            let rgb = bilinear_sample(src, sx, sy);
263            out.put_pixel(gx as u32, gy as u32, image::Rgb(rgb));
264        }
265    }
266    out
267}
268
269#[inline]
270fn bilinear_sample(src: &image::RgbImage, sx: f32, sy: f32) -> [u8; 3] {
271    let sw = src.width() as i32;
272    let sh = src.height() as i32;
273
274    let x0 = sx.floor() as i32;
275    let y0 = sy.floor() as i32;
276    let x1 = x0 + 1;
277    let y1 = y0 + 1;
278
279    let wx1 = sx - sx.floor();
280    let wy1 = sy - sy.floor();
281    let wx0 = 1.0 - wx1;
282    let wy0 = 1.0 - wy1;
283
284    let p00 = get_pixel_clamped(src, x0, y0, sw, sh);
285    let p01 = get_pixel_clamped(src, x1, y0, sw, sh);
286    let p10 = get_pixel_clamped(src, x0, y1, sw, sh);
287    let p11 = get_pixel_clamped(src, x1, y1, sw, sh);
288
289    let mut out = [0u8; 3];
290    for c in 0..3 {
291        let v = wy0 * (wx0 * p00[c] as f32 + wx1 * p01[c] as f32)
292              + wy1 * (wx0 * p10[c] as f32 + wx1 * p11[c] as f32);
293        out[c] = v.round().clamp(0.0, 255.0) as u8;
294    }
295    out
296}
297
298#[inline]
299fn get_pixel_clamped(img: &image::RgbImage, x: i32, y: i32, w: i32, h: i32) -> image::Rgb<u8> {
300    let xc = x.clamp(0, w - 1) as u32;
301    let yc = y.clamp(0, h - 1) as u32;
302    *img.get_pixel(xc, yc)
303}
304
305/// Converte un blob float32 CHW [0,1] in `RgbImage`.
306fn float_chw_to_rgb(data: &[f32], h: u32, w: u32) -> image::RgbImage {
307    let hw = (h * w) as usize;
308    let mut img = image::RgbImage::new(w, h);
309    for y in 0..h as usize {
310        for x in 0..w as usize {
311            let i = y * w as usize + x;
312            let r = (data[i].clamp(0.0, 1.0) * 255.0).round() as u8;
313            let g = (data[hw + i].clamp(0.0, 1.0) * 255.0).round() as u8;
314            let b = (data[2 * hw + i].clamp(0.0, 1.0) * 255.0).round() as u8;
315            img.put_pixel(x as u32, y as u32, image::Rgb([r, g, b]));
316        }
317    }
318    img
319}
320
321// ─── Helpers ─────────────────────────────────────────────────────────────────
322
323/// Upsampling bilineare di una griglia di coordinate normalizzate da
324/// `(src_w × src_h)` a `(dst_w × dst_h)`.
325///
326/// Equivalente a `F.interpolate(grid, size=(dst_h, dst_w), mode="bilinear")`
327/// in PaddleX — usato quando la griglia è a risoluzione ridotta rispetto
328/// all'immagine originale.
329fn upsample_grid(
330    grid: &[(f32, f32)],
331    src_w: u32,
332    src_h: u32,
333    dst_w: u32,
334    dst_h: u32,
335) -> Vec<(f32, f32)> {
336    if src_w == dst_w && src_h == dst_h {
337        return grid.to_vec();
338    }
339    let mut out = Vec::with_capacity((dst_w * dst_h) as usize);
340    for dy in 0..dst_h {
341        for dx in 0..dst_w {
342            // Coordinate nel grid sorgente (floating point)
343            let sx = dx as f32 * (src_w - 1) as f32 / (dst_w - 1).max(1) as f32;
344            let sy = dy as f32 * (src_h - 1) as f32 / (dst_h - 1).max(1) as f32;
345
346            let x0 = sx.floor() as i32;
347            let y0 = sy.floor() as i32;
348            let x1 = (x0 + 1).min(src_w as i32 - 1);
349            let y1 = (y0 + 1).min(src_h as i32 - 1);
350            let wx = sx - sx.floor();
351            let wy = sy - sy.floor();
352
353            let idx = |x: i32, y: i32| (y * src_w as i32 + x) as usize;
354            let g00 = grid[idx(x0.max(0), y0.max(0))];
355            let g10 = grid[idx(x1, y0.max(0))];
356            let g01 = grid[idx(x0.max(0), y1)];
357            let g11 = grid[idx(x1, y1)];
358
359            let interp = |a: f32, b: f32, c: f32, d: f32| {
360                (1.0 - wy) * ((1.0 - wx) * a + wx * b) + wy * ((1.0 - wx) * c + wx * d)
361            };
362            out.push((interp(g00.0, g10.0, g01.0, g11.0), interp(g00.1, g10.1, g01.1, g11.1)));
363        }
364    }
365    out
366}