Skip to main content

uzor_vectorize/
lib.rs

1//! PNG/JPEG → SVG tracer for flat-color graphics.
2//!
3//! Inverse of [`uzor-icon`](https://docs.rs/uzor-icon): that crate rasterizes
4//! SVG, this one vectorizes a raster. Labels come from `uzor-vision`.
5
6mod contour;
7mod quantize;
8mod region;
9mod segment;
10mod svg;
11
12use std::path::Path;
13
14use image::RgbImage;
15use resvg::usvg::{Options as UsvgOptions, Tree};
16use tiny_skia::{Pixmap, Transform};
17
18pub use contour::Pt;
19pub use uzor_vision::GpuMode;
20
21#[derive(Clone, Debug)]
22pub struct VectorizeOptions {
23    pub colors: u32,
24    pub merge: f32,
25    pub min_area: u32,
26    pub epsilon: f32,
27    pub absorb_dist: f32,
28    pub kmeans_iters: u32,
29    /// 3×3 majority on palette labels. Off: it ate thin black outlines.
30    pub majority: bool,
31    /// Felzenszwalb `k` / size. Higher = coarser regions. 0 = median-cut.
32    pub tau: f32,
33    pub gpu: GpuMode,
34    /// Superpixels. 0 = Felzenszwalb (`tau`) or median-cut.
35    pub slic: u32,
36}
37
38impl Default for VectorizeOptions {
39    fn default() -> Self {
40        Self {
41            colors: 48,
42            merge: 14.0,
43            min_area: 32,
44            epsilon: 0.4,
45            absorb_dist: 48.0,
46            kmeans_iters: 0,
47            majority: false,
48            tau: 80.0,
49            gpu: GpuMode::Hybrid,
50            slic: 512,
51        }
52    }
53}
54
55#[derive(Debug)]
56pub struct SvgDocument {
57    pub svg: String,
58    pub width: u32,
59    pub height: u32,
60    pub colors_kept: u32,
61    pub contours: u32,
62    /// Packed RGB of the quantized source (length = width*height*3).
63    pub quantized: Vec<u8>,
64    pub denoise_device: String,
65}
66
67#[derive(Debug)]
68pub enum VectorizeError {
69    Image(String),
70    Empty,
71    Raster(String),
72}
73
74impl std::fmt::Display for VectorizeError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            VectorizeError::Image(s) => write!(f, "image: {s}"),
78            VectorizeError::Empty => write!(f, "empty image"),
79            VectorizeError::Raster(s) => write!(f, "raster: {s}"),
80        }
81    }
82}
83
84impl std::error::Error for VectorizeError {}
85
86fn loop_len(lp: &[contour::Pt]) -> f32 {
87    let mut s = 0.0f32;
88    for i in 1..lp.len() {
89        let dx = (lp[i].0 - lp[i - 1].0) as f32;
90        let dy = (lp[i].1 - lp[i - 1].1) as f32;
91        s += (dx * dx + dy * dy).sqrt();
92    }
93    s
94}
95
96pub fn vectorize_path(
97    path: impl AsRef<Path>,
98    opt: &VectorizeOptions,
99) -> Result<SvgDocument, VectorizeError> {
100    let img = image::open(path.as_ref())
101        .map_err(|e| VectorizeError::Image(e.to_string()))?
102        .to_rgb8();
103    vectorize_rgb(&img, opt)
104}
105
106pub fn vectorize_rgb(img: &RgbImage, opt: &VectorizeOptions) -> Result<SvgDocument, VectorizeError> {
107    let w = img.width() as usize;
108    let h = img.height() as usize;
109    if w == 0 || h == 0 {
110        return Err(VectorizeError::Empty);
111    }
112    let mut rgb = Vec::with_capacity(w * h * 3);
113    for p in img.pixels() {
114        rgb.extend_from_slice(&p.0);
115    }
116    let orig = rgb.clone();
117
118    let vcfg = uzor_vision::Config {
119        gpu: opt.gpu,
120        slic_k: opt.slic,
121        merge: opt.merge,
122    };
123    let frame = uzor_vision::process(&rgb, w, h, &vcfg);
124    let rgb = frame.rgb;
125    let denoise_device = frame.device;
126    let (mut idx, mut pal) = if opt.slic > 0 && !frame.idx.is_empty() {
127        (frame.idx, frame.pal)
128    } else if opt.tau > 0.0 {
129        let sp = segment::felzenszwalb(&rgb, w, h, opt.tau, opt.min_area);
130        let mut idx = sp.0;
131        let mut pal = sp.1;
132        region::merge_adjacent_similar(&mut idx, &mut pal, w, h, opt.merge);
133        (idx, pal)
134    } else {
135        let k = opt.colors.max(2) as usize;
136        quantize::median_cut(&rgb, k)
137    };
138    if opt.kmeans_iters > 0 {
139        quantize::kmeans_refine(&rgb, &mut idx, &mut pal, opt.kmeans_iters);
140    }
141    if opt.slic == 0 && opt.tau <= 0.0 {
142        quantize::merge_similar(&mut idx, &mut pal, opt.merge);
143    }
144    if opt.majority {
145        region::majority_snap(&mut idx, w, h);
146    }
147    region::absorb_speckles(
148        &mut idx,
149        &pal,
150        w,
151        h,
152        opt.min_area,
153        opt.absorb_dist,
154    );
155    region::despeckle_labels(&mut idx, w, h);
156    region::fill_small_holes(&mut idx, w, h, 16);
157    quantize::snap_palette_median(&orig, &idx, &mut pal, w, h);
158
159    let mut quantized = vec![0u8; w * h * 3];
160    for i in 0..w * h {
161        let c = pal[idx[i] as usize];
162        quantized[i * 3] = c[0];
163        quantized[i * 3 + 1] = c[1];
164        quantized[i * 3 + 2] = c[2];
165    }
166
167    let mut area_of = vec![0u32; pal.len()];
168    for &lab in idx.iter() {
169        let i = lab as usize;
170        if i < area_of.len() {
171            area_of[i] += 1;
172        }
173    }
174    let img_area = (w * h) as u32;
175    let mut fills: Vec<(u32, [u8; 3], Vec<Vec<contour::Pt>>)> = Vec::new();
176    let mut strokes: Vec<(u32, [u8; 3], f32, Vec<Vec<contour::Pt>>)> = Vec::new();
177    let mut contour_count = 0u32;
178    for (lab, loops) in contour::planar_loops(&idx, w, h) {
179        let li = lab as usize;
180        if li >= pal.len() {
181            continue;
182        }
183        let area = area_of[li];
184        if area < opt.min_area || loops.is_empty() {
185            continue;
186        }
187        let mut peri = 0.0f32;
188        for lp in &loops {
189            peri += loop_len(lp);
190        }
191        contour_count += loops.len() as u32;
192        let color = pal[li];
193        let width = 2.0 * area as f32 / peri.max(1.0);
194        let luma = (color[0] as u32 + color[1] as u32 + color[2] as u32) / 3;
195        let thin_dark = luma < 40 && width < 2.2 && area < img_area / 20;
196        if thin_dark {
197            strokes.push((area, color, width.clamp(0.8, 5.0), loops));
198        } else {
199            fills.push((area, color, loops));
200        }
201    }
202    fills.sort_by(|a, b| b.0.cmp(&a.0));
203    let mut fill_by_color: Vec<(u32, [u8; 3], Vec<Vec<contour::Pt>>)> = Vec::new();
204    for (area, color, loops) in fills {
205        if let Some(slot) = fill_by_color.iter_mut().find(|s| s.1 == color) {
206            slot.0 += area;
207            slot.2.extend(loops);
208        } else {
209            fill_by_color.push((area, color, loops));
210        }
211    }
212    fill_by_color.sort_by(|a, b| b.0.cmp(&a.0));
213    let mut paths: Vec<String> = Vec::new();
214    for (_, color, loops) in fill_by_color {
215        let hex = svg::hex_color(color);
216        paths.push(svg::fill_path(&hex, &svg::path_d(&loops)));
217    }
218    let mut stroke_by_color: Vec<(u32, [u8; 3], f32, u32, Vec<Vec<contour::Pt>>)> = Vec::new();
219    for (area, color, width, loops) in strokes {
220        if let Some(slot) = stroke_by_color.iter_mut().find(|s| s.1 == color) {
221            slot.0 += area;
222            slot.2 += width * area as f32;
223            slot.3 += area;
224            slot.4.extend(loops);
225        } else {
226            stroke_by_color.push((area, color, width * area as f32, area, loops));
227        }
228    }
229    for (_, color, wsum, warea, loops) in stroke_by_color {
230        let hex = svg::hex_color(color);
231        let sw = (wsum / warea.max(1) as f32).clamp(0.8, 5.0);
232        paths.push(svg::stroke_path(&hex, sw, &svg::path_d(&loops)));
233    }
234    let colors_kept = paths.len() as u32;
235    let svg = svg::document(w as u32, h as u32, &paths);
236    Ok(SvgDocument {
237        svg,
238        width: w as u32,
239        height: h as u32,
240        colors_kept,
241        contours: contour_count,
242        quantized,
243        denoise_device,
244    })
245}
246
247pub fn rasterize_svg(svg: &str, width: u32, height: u32) -> Result<RgbImage, VectorizeError> {
248    let tree = Tree::from_data(svg.as_bytes(), &UsvgOptions::default())
249        .map_err(|e| VectorizeError::Raster(e.to_string()))?;
250    let mut pixmap = Pixmap::new(width, height)
251        .ok_or_else(|| VectorizeError::Raster("pixmap".into()))?;
252    let sz = tree.size();
253    let transform = Transform::from_scale(width as f32 / sz.width(), height as f32 / sz.height());
254    resvg::render(&tree, transform, &mut pixmap.as_mut());
255    let data = pixmap.data();
256    let mut img = RgbImage::new(width, height);
257    for y in 0..height {
258        for x in 0..width {
259            let i = ((y * width + x) * 4) as usize;
260            // resvg writes RGBA; transparent is 0,0,0,0 — treat as black
261            img.put_pixel(x, y, image::Rgb([data[i], data[i + 1], data[i + 2]]));
262        }
263    }
264    Ok(img)
265}
266
267#[derive(Debug, Clone)]
268pub struct ParityStats {
269    pub mae: f32,
270    pub close_pct: f32,
271    pub visible_pct: f32,
272}
273
274pub fn parity_rgb(a: &RgbImage, b: &RgbImage) -> ParityStats {
275    let w = a.width().min(b.width());
276    let h = a.height().min(b.height());
277    let n = (w * h) as f32;
278    let mut sum = 0.0f32;
279    let mut close = 0u32;
280    let mut vis = 0u32;
281    for y in 0..h {
282        for x in 0..w {
283            let pa = a.get_pixel(x, y).0;
284            let pb = b.get_pixel(x, y).0;
285            let mut maxc = 0i32;
286            for c in 0..3 {
287                let d = (pa[c] as i32 - pb[c] as i32).abs();
288                sum += d as f32;
289                if d > maxc {
290                    maxc = d;
291                }
292            }
293            if maxc <= 8 {
294                close += 1;
295            }
296            if maxc > 18 {
297                vis += 1;
298            }
299        }
300    }
301    ParityStats {
302        mae: sum / (n * 3.0),
303        close_pct: close as f32 / n * 100.0,
304        visible_pct: vis as f32 / n * 100.0,
305    }
306}
307
308/// 2×2 contact: source | svg / quant | heatmap. Debug loop reads this.
309pub fn parity_sheet(src: &RgbImage, svg: &RgbImage, quant: &RgbImage, diff: &RgbImage) -> RgbImage {
310    let w = src.width();
311    let h = src.height();
312    let mut sheet = RgbImage::new(w * 2, h * 2);
313    blit(&mut sheet, src, 0, 0);
314    blit(&mut sheet, svg, w, 0);
315    blit(&mut sheet, quant, 0, h);
316    blit(&mut sheet, diff, w, h);
317    sheet
318}
319
320fn blit(dst: &mut RgbImage, src: &RgbImage, ox: u32, oy: u32) {
321    let iw = src.width();
322    let ih = src.height();
323    for y in 0..ih {
324        for x in 0..iw {
325            dst.put_pixel(ox + x, oy + y, *src.get_pixel(x, y));
326        }
327    }
328}
329
330pub fn quantized_image(doc: &SvgDocument) -> RgbImage {
331    let mut img = RgbImage::new(doc.width, doc.height);
332    for y in 0..doc.height {
333        for x in 0..doc.width {
334            let i = ((y * doc.width + x) * 3) as usize;
335            img.put_pixel(
336                x,
337                y,
338                image::Rgb([
339                    doc.quantized[i],
340                    doc.quantized[i + 1],
341                    doc.quantized[i + 2],
342                ]),
343            );
344        }
345    }
346    img
347}
348
349pub fn diff_heatmap(src: &RgbImage, svg: &RgbImage) -> RgbImage {
350    let w = src.width().min(svg.width());
351    let h = src.height().min(svg.height());
352    let mut out = RgbImage::new(w, h);
353    for y in 0..h {
354        for x in 0..w {
355            let a = src.get_pixel(x, y).0;
356            let b = svg.get_pixel(x, y).0;
357            let mut maxc = 0i32;
358            for c in 0..3 {
359                maxc = maxc.max((a[c] as i32 - b[c] as i32).abs());
360            }
361            let luma = (((a[0] as u16 + a[1] as u16 + a[2] as u16) / 3) * 35 / 100) as u8;
362            let heat = if maxc <= 8 {
363                0u8
364            } else {
365                ((maxc - 8) * 4).clamp(0, 255) as u8
366            };
367            out.put_pixel(
368                x,
369                y,
370                image::Rgb([luma.saturating_add(heat), luma, luma]),
371            );
372        }
373    }
374    out
375}
376
377#[derive(Clone, Debug)]
378pub struct Hotspot {
379    pub x: u32,
380    pub y: u32,
381    pub w: u32,
382    pub h: u32,
383    pub area: u32,
384    pub mean_err: f32,
385}
386
387/// Largest visible-error blobs (max-channel > 18). Agent control plane:
388/// one crop per hotspot, not one sheet of the whole image.
389pub fn vis_hotspots(src: &RgbImage, svg: &RgbImage, n: usize) -> Vec<Hotspot> {
390    let w = src.width().min(svg.width()) as usize;
391    let h = src.height().min(svg.height()) as usize;
392    let mut vis = vec![false; w * h];
393    let mut err = vec![0u16; w * h];
394    for y in 0..h {
395        for x in 0..w {
396            let a = src.get_pixel(x as u32, y as u32).0;
397            let b = svg.get_pixel(x as u32, y as u32).0;
398            let mut maxc = 0i32;
399            for c in 0..3 {
400                maxc = maxc.max((a[c] as i32 - b[c] as i32).abs());
401            }
402            if maxc > 18 {
403                vis[y * w + x] = true;
404                err[y * w + x] = maxc as u16;
405            }
406        }
407    }
408    let mut parent: Vec<u32> = (0..(w * h) as u32).collect();
409    let find = |p: &mut [u32], mut a: u32| -> u32 {
410        while p[a as usize] != a {
411            let n = p[a as usize];
412            p[a as usize] = p[n as usize];
413            a = n;
414        }
415        a
416    };
417    for y in 0..h {
418        for x in 0..w {
419            let i = y * w + x;
420            if !vis[i] {
421                continue;
422            }
423            if x + 1 < w && vis[i + 1] {
424                let ra = find(&mut parent, i as u32);
425                let rb = find(&mut parent, (i + 1) as u32);
426                if ra != rb {
427                    parent[rb as usize] = ra;
428                }
429            }
430            if y + 1 < h && vis[i + w] {
431                let ra = find(&mut parent, i as u32);
432                let rb = find(&mut parent, (i + w) as u32);
433                if ra != rb {
434                    parent[rb as usize] = ra;
435                }
436            }
437        }
438    }
439    use std::collections::HashMap;
440    let mut acc: HashMap<u32, (u32, u32, u32, u32, u32, u32)> = HashMap::new();
441    // root -> minx miny maxx maxy area errsum
442    for y in 0..h {
443        for x in 0..w {
444            let i = y * w + x;
445            if !vis[i] {
446                continue;
447            }
448            let r = find(&mut parent, i as u32);
449            let e = acc.entry(r).or_insert((x as u32, y as u32, x as u32, y as u32, 0, 0));
450            e.0 = e.0.min(x as u32);
451            e.1 = e.1.min(y as u32);
452            e.2 = e.2.max(x as u32);
453            e.3 = e.3.max(y as u32);
454            e.4 += 1;
455            e.5 += err[i] as u32;
456        }
457    }
458    let mut spots: Vec<Hotspot> = acc
459        .into_values()
460        .filter(|e| e.4 >= 40)
461        .map(|e| Hotspot {
462            x: e.0,
463            y: e.1,
464            w: e.2 - e.0 + 1,
465            h: e.3 - e.1 + 1,
466            area: e.4,
467            mean_err: e.5 as f32 / e.4 as f32,
468        })
469        .collect();
470    spots.sort_by(|a, b| b.area.cmp(&a.area));
471    spots.truncate(n.max(1));
472    let pad = 24u32;
473    let ww = src.width();
474    let hh = src.height();
475    for s in spots.iter_mut() {
476        let x0 = s.x.saturating_sub(pad);
477        let y0 = s.y.saturating_sub(pad);
478        let x1 = (s.x + s.w + pad).min(ww);
479        let y1 = (s.y + s.h + pad).min(hh);
480        s.x = x0;
481        s.y = y0;
482        s.w = x1 - x0;
483        s.h = y1 - y0;
484    }
485    spots
486}
487
488pub fn hotspot_strip(src: &RgbImage, svg: &RgbImage, heat: &RgbImage, hs: &Hotspot) -> RgbImage {
489    let mut strip = RgbImage::new(hs.w * 3, hs.h);
490    for y in 0..hs.h {
491        for x in 0..hs.w {
492            let sx = hs.x + x;
493            let sy = hs.y + y;
494            strip.put_pixel(x, y, *src.get_pixel(sx, sy));
495            strip.put_pixel(hs.w + x, y, *svg.get_pixel(sx, sy));
496            strip.put_pixel(hs.w * 2 + x, y, *heat.get_pixel(sx, sy));
497        }
498    }
499    strip
500}