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}