Add mosaic mode: seam-free tessellation (pixel + polygon)

Implements the topological mosaic pipeline from docs/design/mosaic.md, turning
`--hierarchical cutout` into a true gapless tessellation instead of the old
re-cluster-and-retrace fake.

  LabelMap (flatten Segmentation top-down)
    → boundary-graph extraction  (integer-exact: corners, node rule, segment
                                   and ring tracing on the pixel-corner lattice)
    → face assembly              (left-region successor rule; winding falls out,
                                   so each region is one nonzero-fill path)
    → fit each segment ONCE       (shared by both adjacent faces, reversed
                                   exactly → byte-identical shared boundaries)
    → compose per-region paths

Backends: PixelSegmentFitter (exact reference) and PolygonSegmentFitter
(symmetric open Douglas-Peucker collapsing staircases to the crack midline).
The spline segment fitter is still pending; mosaic + spline currently falls
back to polygon.

Compositing now owns its fitter (Stacked(CurveFitter) / Mosaic(SegmentFitter)).

Tests: single region, vertical split, T-junction, checkerboard pinch, nested
rings, border-touching, and a pixel round-trip property test over 40 random
maps (rasterize composed faces == input label map). Plus two mosaic goldens.
This commit is contained in:
Chris Tsang
2026-07-23 23:26:18 +01:00
parent 572d9e5f82
commit 17a9a6e6c5
13 changed files with 1177 additions and 18 deletions
+15 -3
View File
@@ -7,12 +7,24 @@
use crate::fitter::CurveFitter;
use crate::ir::{Segmentation, Shape, VectorDoc};
use crate::mosaic::{compose_mosaic, MosaicOptions, SegmentFitter};
/// Which compositing strategy the pipeline uses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Which compositing strategy the pipeline uses. Each variant owns its fitter.
pub enum Compositing {
/// Independent per-region closed outlines, stacked bottom-to-top.
Stacked,
Stacked(Box<dyn CurveFitter>),
/// Seam-free gapless tessellation via a shared boundary graph.
Mosaic(Box<dyn SegmentFitter>, MosaicOptions),
}
impl Compositing {
/// Run the selected compositor over a segmentation.
pub fn compose(&self, seg: &Segmentation) -> VectorDoc {
match self {
Compositing::Stacked(fitter) => compose_stacked(seg, fitter.as_ref()),
Compositing::Mosaic(fitter, opts) => compose_mosaic(seg, fitter.as_ref(), opts),
}
}
}
/// Trace every layer's closed outline and stack the shapes in paint order.
+13 -5
View File
@@ -9,6 +9,7 @@ use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::{CurveFitter, FitParams, PixelFitter, PolygonFitter, SplineFitter};
use crate::frontend::{BinaryFrontend, ColorClusterFrontend, Frontend};
use crate::mosaic::{MosaicOptions, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter};
use crate::optimize::{OptimizerPass, QuantizePass, SimplifyPass};
use crate::pipeline::Pipeline;
use crate::svg::SvgWriter;
@@ -160,6 +161,16 @@ impl Config {
}
}
fn segment_fitter(&self) -> Box<dyn SegmentFitter> {
match self.mode {
FitMode::Pixel => Box::new(PixelSegmentFitter),
FitMode::Polygon => Box::new(PolygonSegmentFitter::default()),
// The spline segment fitter is not implemented yet; mosaic falls
// back to the polygon (crack-midline) fitter for now.
FitMode::Spline => Box::new(PolygonSegmentFitter::default()),
}
}
fn optimizers(&self) -> Vec<Box<dyn OptimizerPass>> {
if self.optimize == 0 {
return Vec::new();
@@ -194,18 +205,15 @@ impl Config {
/// Assemble a concrete pipeline from this configuration.
pub fn build(&self) -> Result<Pipeline, Error> {
let compositing = match self.hierarchical {
Hierarchical::Stacked => Compositing::Stacked,
Hierarchical::Stacked => Compositing::Stacked(self.fitter()),
Hierarchical::Cutout => {
return Err(Error::Unsupported(
"the mosaic (cutout) compositor is not yet implemented".into(),
))
Compositing::Mosaic(self.segment_fitter(), MosaicOptions::default())
}
};
Ok(Pipeline {
frontend: self.frontend(),
color_fitters: self.color_fitters(),
fitter: self.fitter(),
compositing,
optimizers: self.optimizers(),
writer: self.writer(),
+1
View File
@@ -36,6 +36,7 @@ pub mod error;
pub mod fitter;
pub mod frontend;
pub mod ir;
pub mod mosaic;
pub mod optimize;
pub mod pipeline;
pub mod svg;
+116
View File
@@ -0,0 +1,116 @@
//! Stage 4: compose per-region SVG paths from shared fitted segments.
//!
//! Each region becomes one shape whose `d` concatenates its contours as
//! subpaths (default `nonzero` fill rule handles holes and pinch points). Each
//! oriented segment is emitted skipping its first point (identical to the
//! previous segment's last point), so shared boundaries are byte-identical on
//! both sides.
use crate::ir::{MultiPath, PathCmd, Shape, SubPath, VectorDoc};
use visioncortex::PointF64;
use super::face::{assemble, Contour, Face};
use super::fit::{FittedGeom, FittedSegment, SegmentFitter};
use super::graph::BoundaryGraph;
use super::{LabelMap, MosaicOptions, Segmentation};
/// Run the full mosaic pipeline: flatten → boundary graph → faces → fit → compose.
pub fn compose_mosaic(
seg: &Segmentation,
fitter: &dyn SegmentFitter,
_options: &MosaicOptions,
) -> VectorDoc {
let map = LabelMap::from_segmentation(seg);
let graph = BoundaryGraph::extract(&map);
let faces = assemble(&graph, &map);
// Fit every segment exactly once; both adjacent faces share the result.
let fitted: Vec<FittedSegment> = graph
.segments
.iter()
.map(|s| {
if s.is_ring() {
fitter.fit_ring(s)
} else {
fitter.fit_open(s)
}
})
.collect();
let mut doc = VectorDoc::new(seg.width, seg.height);
for face in &faces {
let path = build_path(face, &fitted, &graph);
if !path.is_empty() {
doc.shapes.push(Shape {
paint: map.paints[face.region as usize],
path,
});
}
}
doc
}
fn build_path(face: &Face, fitted: &[FittedSegment], _graph: &BoundaryGraph) -> MultiPath {
let mut mp = MultiPath::new();
for contour in &face.contours {
let mut sub = SubPath::new();
emit_contour(contour, fitted, &mut sub);
if !sub.is_empty() {
sub.commands.push(PathCmd::Close);
mp.subpaths.push(sub);
}
}
mp
}
fn emit_contour(contour: &Contour, fitted: &[FittedSegment], sub: &mut SubPath) {
for (i, sref) in contour.0.iter().enumerate() {
let geom = &fitted[sref.seg as usize].geom;
emit_segment(geom, sref.forward, i == 0, sub);
}
}
/// Append one oriented segment's commands. When `first`, opens with a `MoveTo`;
/// otherwise the leading point (shared with the previous segment) is skipped.
fn emit_segment(geom: &FittedGeom, forward: bool, first: bool, sub: &mut SubPath) {
match geom {
FittedGeom::Polyline(pts) => {
if pts.len() < 2 {
return;
}
let ordered: Vec<PointF64> = if forward {
pts.clone()
} else {
pts.iter().rev().copied().collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0]));
}
for p in &ordered[1..] {
sub.commands.push(PathCmd::LineTo(*p));
}
}
FittedGeom::Beziers(curves) => {
if curves.is_empty() {
return;
}
// Reversing a cubic is exact: [p0,p1,p2,p3] -> [p3,p2,p1,p0], and
// the whole chain reverses in order too.
let ordered: Vec<[PointF64; 4]> = if forward {
curves.clone()
} else {
curves
.iter()
.rev()
.map(|c| [c[3], c[2], c[1], c[0]])
.collect()
};
if first {
sub.commands.push(PathCmd::MoveTo(ordered[0][0]));
}
for c in &ordered {
sub.commands.push(PathCmd::CubicTo(c[1], c[2], c[3]));
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Stage 2: face assembly.
//!
//! Lift the "region kept on the left" successor rule from unit edges to whole
//! segments. Following it around each region yields its contours; because the
//! interior is always on the left, outer contours and hole contours come out
//! with opposite winding automatically — no containment/nesting computation is
//! needed, and the region can be filled with a single `nonzero` path.
use super::graph::{
edge_present, left_pixel_at, reverse, straight, turn_left, turn_right, BoundaryGraph, SegRef,
};
use super::{LabelMap, RegionId, OUTSIDE};
/// A closed cycle of directed segments bounding (part of) a region.
#[derive(Clone, Debug)]
pub struct Contour(pub Vec<SegRef>);
/// One region and all of its contours (outer + holes).
#[derive(Clone, Debug)]
pub struct Face {
pub region: RegionId,
pub contours: Vec<Contour>,
}
/// Left region of a directed segment view.
fn left_region(graph: &BoundaryGraph, r: SegRef) -> RegionId {
let seg = &graph.segments[r.seg as usize];
if r.forward {
seg.left
} else {
seg.right
}
}
/// Pick the next unit direction leaving `corner`, keeping region `r` on the
/// left: sharpest right turn first (this pinches checkerboard nodes and keeps
/// contours simple).
fn successor(map: &LabelMap, x: i32, y: i32, d_in: u8, r: RegionId) -> u8 {
for &d in &[turn_right(d_in), straight(d_in), turn_left(d_in)] {
if edge_present(map, x, y, d) && left_pixel_at(map, x, y, d) == r {
return d;
}
}
unreachable!("no successor edge keeps the region on the left");
}
pub fn assemble(graph: &BoundaryGraph, map: &LabelMap) -> Vec<Face> {
let mut by_region: Vec<Vec<Contour>> = vec![Vec::new(); map.paints.len()];
// usage[seg][0] = forward view used, [1] = backward view used.
let mut used = vec![[false; 2]; graph.segments.len()];
for seg_id in 0..graph.segments.len() {
if graph.segments[seg_id].is_ring() {
continue;
}
for &forward in &[true, false] {
let start = SegRef {
seg: seg_id as u32,
forward,
};
let region = left_region(graph, start);
if region == OUTSIDE || used[seg_id][forward as usize] {
continue;
}
let mut contour = Vec::new();
let mut cur = start;
loop {
used[cur.seg as usize][cur.forward as usize] = true;
contour.push(cur);
let seg = &graph.segments[cur.seg as usize];
let (node_id, d_in) = if cur.forward {
(seg.end.unwrap(), seg.last_dir)
} else {
(seg.start.unwrap(), reverse(seg.first_dir))
};
let corner = graph.nodes[node_id as usize].corner;
let d_next = successor(map, corner.x, corner.y, d_in, region);
cur = graph.nodes[node_id as usize].out[d_next as usize]
.expect("successor direction must have an outgoing segment");
if cur == start {
break;
}
}
if (region as usize) < by_region.len() {
by_region[region as usize].push(Contour(contour));
}
}
}
// Rings: the left side uses it forward, the right side reversed.
for seg_id in 0..graph.segments.len() {
let seg = &graph.segments[seg_id];
if !seg.is_ring() {
continue;
}
if seg.left != OUTSIDE && (seg.left as usize) < by_region.len() {
by_region[seg.left as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: true,
}]));
}
if seg.right != OUTSIDE && (seg.right as usize) < by_region.len() {
by_region[seg.right as usize].push(Contour(vec![SegRef {
seg: seg_id as u32,
forward: false,
}]));
}
}
by_region
.into_iter()
.enumerate()
.filter(|(_, c)| !c.is_empty())
.map(|(region, contours)| Face {
region: region as RegionId,
contours,
})
.collect()
}
+173
View File
@@ -0,0 +1,173 @@
//! Stage 3: fit each boundary segment once, with endpoints pinned to nodes.
//!
//! A segment is fitted a single time and cached; both adjacent faces reference
//! the same [`FittedSegment`], one traversed reversed. Reversal is exact, so
//! the shared geometry is bitwise identical and no seam can appear.
use visioncortex::{PointF64, PointI32};
use super::graph::Segment;
/// Fitted geometry for one boundary segment.
#[derive(Clone, Debug)]
pub enum FittedGeom {
/// Polyline (pixel / polygon backends).
Polyline(Vec<PointF64>),
/// Chain of cubic Béziers; consecutive curves share endpoints (spline backend).
Beziers(Vec<[PointF64; 4]>),
}
/// A fitted segment, cached and indexed by segment id.
#[derive(Clone, Debug)]
pub struct FittedSegment {
pub geom: FittedGeom,
}
/// Fits a single boundary segment. `fit_open` pins both endpoints (junction
/// nodes must not move); `fit_ring` fits a closed loop with no pinned point.
pub trait SegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment;
fn fit_ring(&self, seg: &Segment) -> FittedSegment;
}
fn to_f64(points: &[PointI32]) -> Vec<PointF64> {
points
.iter()
.map(|p| PointF64 {
x: p.x as f64,
y: p.y as f64,
})
.collect()
}
/// Identity fitter: lattice points as f64. Produces an exact tessellation and
/// is the reference backend for tests.
#[derive(Debug, Clone, Default)]
pub struct PixelSegmentFitter;
impl SegmentFitter for PixelSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
FittedSegment {
geom: FittedGeom::Polyline(to_f64(&seg.points)),
}
}
}
/// Symmetric open Douglas–Peucker. Endpoints are always kept, so junction
/// nodes stay pinned. Plain DP (no directional staircase removal) collapses
/// 1-px staircases to the crack midline — centered between the two regions,
/// which is what a mosaic wants.
#[derive(Debug, Clone)]
pub struct PolygonSegmentFitter {
pub tolerance: f64,
}
impl Default for PolygonSegmentFitter {
fn default() -> Self {
Self { tolerance: 0.5 }
}
}
impl SegmentFitter for PolygonSegmentFitter {
fn fit_open(&self, seg: &Segment) -> FittedSegment {
let pts = to_f64(&seg.points);
FittedSegment {
geom: FittedGeom::Polyline(dp_open(&pts, self.tolerance)),
}
}
fn fit_ring(&self, seg: &Segment) -> FittedSegment {
// Closed loop: split at the vertex farthest from the start, DP each
// half, then rejoin. points[0] == points[last].
let pts = to_f64(&seg.points);
if pts.len() <= 4 {
return FittedSegment {
geom: FittedGeom::Polyline(pts),
};
}
let open = &pts[..pts.len() - 1]; // drop duplicate closing point
let far = farthest_from(open, 0);
let first: Vec<PointF64> = open[0..=far].to_vec();
let second: Vec<PointF64> = open[far..]
.iter()
.chain(std::iter::once(&open[0]))
.copied()
.collect();
let mut a = dp_open(&first, self.tolerance);
let b = dp_open(&second, self.tolerance);
// `a` ends at `far`, `b` starts at `far` and ends back at start.
a.pop(); // drop shared `far`
a.extend(b); // ...b includes far..start (closing point == start)
FittedSegment {
geom: FittedGeom::Polyline(a),
}
}
}
fn farthest_from(pts: &[PointF64], anchor: usize) -> usize {
let a = pts[anchor];
let mut best = anchor;
let mut best_d = -1.0;
for (i, p) in pts.iter().enumerate() {
let dx = p.x - a.x;
let dy = p.y - a.y;
let d = dx * dx + dy * dy;
if d > best_d {
best_d = d;
best = i;
}
}
best
}
/// Douglas–Peucker on an open polyline; first and last points are always kept.
fn dp_open(pts: &[PointF64], tol: f64) -> Vec<PointF64> {
if pts.len() <= 2 {
return pts.to_vec();
}
let mut keep = vec![false; pts.len()];
keep[0] = true;
keep[pts.len() - 1] = true;
dp_recurse(pts, 0, pts.len() - 1, tol, &mut keep);
pts.iter()
.zip(keep)
.filter_map(|(p, k)| if k { Some(*p) } else { None })
.collect()
}
fn dp_recurse(pts: &[PointF64], lo: usize, hi: usize, tol: f64, keep: &mut [bool]) {
if hi <= lo + 1 {
return;
}
let mut max_d = -1.0;
let mut idx = lo;
for i in (lo + 1)..hi {
let d = perp_distance(pts[i], pts[lo], pts[hi]);
if d > max_d {
max_d = d;
idx = i;
}
}
if max_d > tol {
keep[idx] = true;
dp_recurse(pts, lo, idx, tol, keep);
dp_recurse(pts, idx, hi, tol, keep);
}
}
/// Perpendicular distance from `p` to the segment `a`–`b`.
fn perp_distance(p: PointF64, a: PointF64, b: PointF64) -> f64 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len2 = dx * dx + dy * dy;
if len2 == 0.0 {
return ((p.x - a.x).powi(2) + (p.y - a.y).powi(2)).sqrt();
}
let cross = (p.x - a.x) * dy - (p.y - a.y) * dx;
cross.abs() / len2.sqrt()
}
+357
View File
@@ -0,0 +1,357 @@
//! Stage 1: boundary-graph extraction from a [`LabelMap`].
//!
//! Pure integer arithmetic on the lattice of pixel corners `0..=W × 0..=H`.
//! Pixel `(x,y)` occupies the unit square `(x,y)..(x+1,y+1)`; boundaries run
//! along the "cracks" between differing labels.
use visioncortex::PointI32;
use super::{LabelMap, RegionId, OUTSIDE};
pub type NodeId = u32;
pub type SegId = u32;
// Unit directions, arranged clockwise in y-down screen space so that
// `(d + 1) % 4` is a right turn and `(d + 2) % 4` is a reversal.
const N: u8 = 0;
const E: u8 = 1;
const S: u8 = 2;
const W: u8 = 3;
/// (dx, dy) per direction.
const DVEC: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
#[inline]
pub(super) fn turn_right(d: u8) -> u8 {
(d + 1) % 4
}
#[inline]
pub(super) fn straight(d: u8) -> u8 {
d
}
#[inline]
pub(super) fn turn_left(d: u8) -> u8 {
(d + 3) % 4
}
#[inline]
pub(super) fn reverse(d: u8) -> u8 {
(d + 2) % 4
}
/// A directed reference to a segment: either traversed forward or reversed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SegRef {
pub seg: SegId,
pub forward: bool,
}
/// A junction corner (degree ≥ 3) with the segment leaving it in each unit
/// direction (if any).
#[derive(Clone, Debug)]
pub struct Node {
pub corner: PointI32,
pub out: [Option<SegRef>; 4],
}
/// A maximal boundary chain between two nodes, or a nodeless ring.
#[derive(Clone, Debug)]
pub struct Segment {
/// Lattice polyline; `len >= 2`. For a ring, `points[0] == points[last]`.
pub points: Vec<PointI32>,
pub start: Option<NodeId>,
pub end: Option<NodeId>,
/// Region on the left when traversing forward (y-down convention).
pub left: RegionId,
pub right: RegionId,
/// Direction of the first edge (leaving `start`); unused for rings.
pub first_dir: u8,
/// Direction of the last edge (arriving at `end`); unused for rings.
pub last_dir: u8,
}
impl Segment {
pub fn is_ring(&self) -> bool {
self.start.is_none()
}
}
/// The extracted boundary graph. Faces are assembled separately (see `face`).
pub struct BoundaryGraph {
pub nodes: Vec<Node>,
pub segments: Vec<Segment>,
}
struct Extractor<'a> {
map: &'a LabelMap,
w: i32,
h: i32,
/// NodeId per lattice corner, `u32::MAX` if not a node. Size (W+1)(H+1).
node_at: Vec<NodeId>,
/// Visited flags for undirected unit edges.
visited_v: Vec<bool>, // vertical edge (x in 0..=W, y in 0..H): y*(W+1)+x
visited_h: Vec<bool>, // horizontal edge (x in 0..W, y in 0..=H): y*W + x
nodes: Vec<Node>,
segments: Vec<Segment>,
}
impl<'a> Extractor<'a> {
fn new(map: &'a LabelMap) -> Self {
let w = map.width as i32;
let h = map.height as i32;
let cw = (map.width + 1) as usize;
let ch = (map.height + 1) as usize;
Extractor {
map,
w,
h,
node_at: vec![u32::MAX; cw * ch],
visited_v: vec![false; (map.width as usize + 1) * map.height as usize],
visited_h: vec![false; map.width as usize * (map.height as usize + 1)],
nodes: Vec::new(),
segments: Vec::new(),
}
}
#[inline]
fn corner_index(&self, x: i32, y: i32) -> usize {
y as usize * (self.w as usize + 1) + x as usize
}
/// 4-bit edge mask (N,E,S,W) present at corner `(x,y)`.
fn edge_mask(&self, x: i32, y: i32) -> u8 {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
let mut m = 0u8;
if nw != ne {
m |= 1 << N;
}
if ne != se {
m |= 1 << E;
}
if sw != se {
m |= 1 << S;
}
if nw != sw {
m |= 1 << W;
}
m
}
/// (left, right) regions flanking the directed edge leaving `(x,y)` in `d`.
fn side_pixels(&self, x: i32, y: i32, d: u8) -> (RegionId, RegionId) {
let nw = self.map.label(x - 1, y - 1);
let ne = self.map.label(x, y - 1);
let sw = self.map.label(x - 1, y);
let se = self.map.label(x, y);
match d {
N => (nw, ne),
E => (ne, se),
S => (se, sw),
W => (sw, nw),
_ => unreachable!(),
}
}
/// Mark/query an undirected unit edge leaving `(x,y)` in direction `d`.
/// Returns the canonical (is_vertical, index).
fn edge_slot(&self, x: i32, y: i32, d: u8) -> (bool, usize) {
match d {
N => (true, (y - 1) as usize * (self.w as usize + 1) + x as usize),
S => (true, y as usize * (self.w as usize + 1) + x as usize),
E => (false, y as usize * self.w as usize + x as usize),
W => (false, y as usize * self.w as usize + (x - 1) as usize),
_ => unreachable!(),
}
}
fn is_visited(&self, x: i32, y: i32, d: u8) -> bool {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i]
} else {
self.visited_h[i]
}
}
fn mark_visited(&mut self, x: i32, y: i32, d: u8) {
let (v, i) = self.edge_slot(x, y, d);
if v {
self.visited_v[i] = true;
} else {
self.visited_h[i] = true;
}
}
/// Pass A — classify corners and allocate node ids for degree ≥ 3.
fn classify(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let deg = self.edge_mask(x, y).count_ones();
if deg >= 3 {
let id = self.nodes.len() as NodeId;
self.nodes.push(Node {
corner: PointI32 { x, y },
out: [None; 4],
});
let ci = self.corner_index(x, y);
self.node_at[ci] = id;
}
}
}
}
fn node_id(&self, x: i32, y: i32) -> Option<NodeId> {
let id = self.node_at[self.corner_index(x, y)];
if id == u32::MAX {
None
} else {
Some(id)
}
}
/// Walk from `(x0,y0)` heading `d0` until a node (or, for rings, back to
/// the start). Returns the polyline, the final heading, and the corner
/// walked to. Marks every traversed edge visited.
fn walk(&mut self, x0: i32, y0: i32, d0: u8) -> (Vec<PointI32>, u8, i32, i32) {
let mut points = vec![PointI32 { x: x0, y: y0 }];
let (mut cx, mut cy, mut d) = (x0, y0, d0);
loop {
self.mark_visited(cx, cy, d);
let (dx, dy) = DVEC[d as usize];
let (nx, ny) = (cx + dx, cy + dy);
points.push(PointI32 { x: nx, y: ny });
let mask = self.edge_mask(nx, ny);
if mask.count_ones() >= 3 {
return (points, d, nx, ny); // reached a node
}
if nx == x0 && ny == y0 {
return (points, d, nx, ny); // closed ring
}
// Degree-2: continue via the unique present edge that is not the
// reverse of how we arrived.
let rev = reverse(d);
let mut nd = d;
for cand in 0..4u8 {
if cand != rev && (mask & (1 << cand)) != 0 {
nd = cand;
break;
}
}
d = nd;
cx = nx;
cy = ny;
}
}
/// Pass B — trace node-to-node segments.
fn trace_segments(&mut self) {
let node_corners: Vec<PointI32> = self.nodes.iter().map(|n| n.corner).collect();
for (nid, corner) in node_corners.iter().enumerate() {
let nid = nid as NodeId;
let (x, y) = (corner.x, corner.y);
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, last_dir, ex, ey) = self.walk(x, y, d);
let end = self
.node_id(ex, ey)
.expect("segment must end at a node");
let seg_id = self.segments.len() as SegId;
self.segments.push(Segment {
points,
start: Some(nid),
end: Some(end),
left,
right,
first_dir: d,
last_dir,
});
self.nodes[nid as usize].out[d as usize] = Some(SegRef {
seg: seg_id,
forward: true,
});
// Leaving the end node backward along this segment.
let back = reverse(last_dir);
self.nodes[end as usize].out[back as usize] = Some(SegRef {
seg: seg_id,
forward: false,
});
}
}
}
/// Pass C — closed rings from any remaining unvisited boundary edges.
fn trace_rings(&mut self) {
for y in 0..=self.h {
for x in 0..=self.w {
let mask = self.edge_mask(x, y);
for d in 0..4u8 {
if (mask & (1 << d)) == 0 || self.is_visited(x, y, d) {
continue;
}
let (left, right) = self.side_pixels(x, y, d);
let (points, _last, _ex, _ey) = self.walk(x, y, d);
self.segments.push(Segment {
points,
start: None,
end: None,
left,
right,
first_dir: d,
last_dir: 0,
});
}
}
}
}
}
impl BoundaryGraph {
pub fn extract(map: &LabelMap) -> BoundaryGraph {
let mut ex = Extractor::new(map);
ex.classify();
ex.trace_segments();
ex.trace_rings();
BoundaryGraph {
nodes: ex.nodes,
segments: ex.segments,
}
}
}
/// Left region flanking the directed edge leaving `(x,y)` in `d` — used by the
/// face-assembly successor rule against a [`LabelMap`].
pub(super) fn left_pixel_at(map: &LabelMap, x: i32, y: i32, d: u8) -> RegionId {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw,
E => ne,
S => se,
W => sw,
_ => OUTSIDE,
}
}
// Direction constants and edge-present test needed by face assembly.
pub(super) fn edge_present(map: &LabelMap, x: i32, y: i32, d: u8) -> bool {
let nw = map.label(x - 1, y - 1);
let ne = map.label(x, y - 1);
let sw = map.label(x - 1, y);
let se = map.label(x, y);
match d {
N => nw != ne,
E => ne != se,
S => sw != se,
W => nw != sw,
_ => false,
}
}
+295
View File
@@ -0,0 +1,295 @@
//! Mosaic mode: a seam-free, gapless tessellation.
//!
//! Instead of tracing every region independently (which lets neighboring
//! smoothed boundaries diverge and crack), the mosaic pipeline is topological:
//!
//! ```text
//! LabelMap → boundary graph → faces → fit each segment ONCE → compose
//! ```
//!
//! Every boundary curve exists exactly once; the two adjacent regions
//! reference the same fitted geometry, one traversed reversed. Reversal is
//! exact, so the serialized coordinates match on both sides — no seams.
//!
//! Stages 1–2 (graph + faces) are pure integer arithmetic on the lattice of
//! pixel corners. Only fitting (stage 3) is floating point.
mod compose;
mod face;
mod fit;
mod graph;
pub use compose::compose_mosaic;
pub use fit::{
FittedSegment, PixelSegmentFitter, PolygonSegmentFitter, SegmentFitter,
};
pub use graph::{BoundaryGraph, Node, Segment, SegRef};
use crate::ir::{Paint, Segmentation};
/// A dense region id. [`OUTSIDE`] marks keyed/transparent/out-of-bounds pixels.
pub type RegionId = u32;
/// Sentinel label for pixels outside any region.
pub const OUTSIDE: RegionId = u32::MAX;
/// Options controlling mosaic fitting and output.
#[derive(Debug, Clone, Copy, Default)]
pub struct MosaicOptions {
/// Sample fitted segments and fall back to the DP polyline on any that
/// exceed the 0.5px deviation budget, restoring a hard no-crossing guarantee.
pub strict: bool,
/// Stroke each path in its own fill color to hide antialiasing hairlines.
pub seam_stroke: bool,
}
/// A flat partition of the canvas: one region id per pixel, plus the paint for
/// each region. This is the sole input to the boundary-graph extractor.
#[derive(Debug, Clone)]
pub struct LabelMap {
pub width: u32,
pub height: u32,
/// One label per pixel in row-major order; `OUTSIDE` for uncovered pixels.
pub labels: Vec<RegionId>,
/// Paint per region, indexed by label.
pub paints: Vec<Paint>,
}
impl LabelMap {
/// Flatten a layered [`Segmentation`] top-down into a flat partition: each
/// pixel takes the paint of the topmost layer covering it. Layers are
/// bottom-to-top, so painting them in order lets higher layers win.
pub fn from_segmentation(seg: &Segmentation) -> Self {
let w = seg.width as usize;
let h = seg.height as usize;
let mut labels = vec![OUTSIDE; w * h];
let paints: Vec<Paint> = seg.layers.iter().map(|l| l.paint).collect();
for (i, layer) in seg.layers.iter().enumerate() {
let mask = &layer.mask;
for ly in 0..mask.image.height {
for lx in 0..mask.image.width {
if mask.image.get_pixel(lx, ly) {
let gx = mask.offset.x + lx as i32;
let gy = mask.offset.y + ly as i32;
if gx >= 0 && gy >= 0 && (gx as usize) < w && (gy as usize) < h {
labels[gy as usize * w + gx as usize] = i as RegionId;
}
}
}
}
}
LabelMap {
width: seg.width,
height: seg.height,
labels,
paints,
}
}
/// Label at pixel `(x, y)`, or [`OUTSIDE`] for out-of-bounds coordinates.
/// Treating outside as a real label removes all image-border special cases.
#[inline]
pub fn label(&self, x: i32, y: i32) -> RegionId {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return OUTSIDE;
}
self.labels[y as usize * self.width as usize + x as usize]
}
}
#[cfg(test)]
mod tests {
use super::face::{assemble, Face};
use super::graph::BoundaryGraph;
use super::*;
use crate::ir::Paint;
use visioncortex::{Color, PointF64};
/// Build a label map from a row-major grid (for tests).
fn grid(width: u32, height: u32, labels: Vec<RegionId>) -> LabelMap {
let max = labels.iter().filter(|&&l| l != OUTSIDE).copied().max();
let n = max.map(|m| m as usize + 1).unwrap_or(0);
let paints = (0..n).map(|_| Paint::Solid(Color::new(0, 0, 0))).collect();
LabelMap {
width,
height,
labels,
paints,
}
}
/// Reconstruct a face's contour polygons in exact lattice coordinates.
fn face_polygons(graph: &BoundaryGraph, face: &Face) -> Vec<Vec<PointF64>> {
face.contours
.iter()
.map(|contour| {
let mut ring: Vec<PointF64> = Vec::new();
for (i, sref) in contour.0.iter().enumerate() {
let pts = &graph.segments[sref.seg as usize].points;
let ordered: Vec<PointF64> = if sref.forward {
pts.iter().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
} else {
pts.iter().rev().map(|p| PointF64 { x: p.x as f64, y: p.y as f64 }).collect()
};
if i == 0 {
ring.extend(ordered);
} else {
ring.extend(ordered[1..].iter().copied());
}
}
ring
})
.collect()
}
fn is_left(a: PointF64, b: PointF64, p: PointF64) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Winding number of point `p` w.r.t. a closed ring (last == first).
fn winding(ring: &[PointF64], p: PointF64) -> i32 {
let mut wn = 0;
for w in ring.windows(2) {
let (a, b) = (w[0], w[1]);
if a.y <= p.y {
if b.y > p.y && is_left(a, b, p) > 0.0 {
wn += 1;
}
} else if b.y <= p.y && is_left(a, b, p) < 0.0 {
wn -= 1;
}
}
wn
}
/// The strongest guarantee: rasterize the composed faces at pixel centers
/// and assert the result is byte-identical to the input label map.
fn assert_pixel_roundtrip(map: &LabelMap) {
let graph = BoundaryGraph::extract(map);
let faces = assemble(&graph, map);
let polys: Vec<(RegionId, Vec<Vec<PointF64>>)> = faces
.iter()
.map(|f| (f.region, face_polygons(&graph, f)))
.collect();
for y in 0..map.height as i32 {
for x in 0..map.width as i32 {
let center = PointF64 {
x: x as f64 + 0.5,
y: y as f64 + 0.5,
};
let mut hits: Vec<RegionId> = Vec::new();
for (region, rings) in &polys {
let wn: i32 = rings.iter().map(|r| winding(r, center)).sum();
if wn != 0 {
hits.push(*region);
}
}
let expected = map.label(x, y);
if expected == OUTSIDE {
assert!(hits.is_empty(), "({x},{y}) OUTSIDE but covered by {hits:?}");
} else {
assert_eq!(
hits,
vec![expected],
"({x},{y}) expected region {expected}, got {hits:?}"
);
}
}
}
}
#[test]
fn single_region_is_one_ring() {
let map = grid(3, 2, vec![0; 6]);
let graph = BoundaryGraph::extract(&map);
assert_eq!(graph.nodes.len(), 0, "no junctions in a single region");
assert_eq!(graph.segments.len(), 1, "one border ring");
assert!(graph.segments[0].is_ring());
assert_pixel_roundtrip(&map);
}
#[test]
fn vertical_split() {
// 4x2, left half 0, right half 1.
let map = grid(4, 2, vec![0, 0, 1, 1, 0, 0, 1, 1]);
let graph = BoundaryGraph::extract(&map);
// Two border junctions where the split meets the top and bottom edges.
assert_eq!(graph.nodes.len(), 2);
assert_pixel_roundtrip(&map);
}
#[test]
fn t_junction() {
// top row one region, bottom row split — a degree-3 interior node.
let map = grid(2, 2, vec![0, 0, 1, 2]);
assert_pixel_roundtrip(&map);
}
#[test]
fn checkerboard_pinch() {
// A B / B A — the center corner is a degree-4 pinch; each region is two
// lobes touching there. (The four boundary/border corners are degree-3
// nodes too, per the border rule — so 5 nodes total.) The round-trip is
// the real check that the pinch produces exact, simple contours.
let map = grid(2, 2, vec![0, 1, 1, 0]);
let graph = BoundaryGraph::extract(&map);
let has_degree4 = graph.nodes.iter().any(|n| {
let c = n.corner;
n.out.iter().filter(|o| o.is_some()).count() == 4 && c.x == 1 && c.y == 1
});
assert!(has_degree4, "expected a degree-4 pinch node at the center");
assert_pixel_roundtrip(&map);
}
#[test]
fn nested_rings() {
// Concentric squares: 0 outer, 1 middle, 2 center.
let l = |x: i32, y: i32| -> RegionId {
let d = x.min(y).min(5 - x).min(5 - y);
match d {
0 => 0,
1 => 1,
_ => 2,
}
};
let mut labels = Vec::new();
for y in 0..6 {
for x in 0..6 {
labels.push(l(x, y));
}
}
assert_pixel_roundtrip(&grid(6, 6, labels));
}
#[test]
fn outside_region_border_touching() {
// A region that does not fill the canvas; the rest is OUTSIDE.
let mut labels = vec![OUTSIDE; 16];
for y in 1..3 {
for x in 1..3 {
labels[y * 4 + x] = 0;
}
}
assert_pixel_roundtrip(&grid(4, 4, labels));
}
#[test]
fn random_maps_roundtrip() {
// Deterministic LCG; connectivity not required.
let mut state: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as u32
};
for _ in 0..40 {
let w = 2 + next() % 10;
let h = 2 + next() % 10;
let nlabels = 1 + next() % 5;
let labels: Vec<RegionId> = (0..w * h).map(|_| next() % nlabels).collect();
assert_pixel_roundtrip(&grid(w, h, labels));
}
}
}
+2 -6
View File
@@ -3,9 +3,8 @@
use visioncortex::ColorImage;
use crate::colorfit::ColorFitter;
use crate::compose::{compose_stacked, Compositing};
use crate::compose::Compositing;
use crate::error::Error;
use crate::fitter::CurveFitter;
use crate::frontend::Frontend;
use crate::ir::VectorDoc;
use crate::optimize::OptimizerPass;
@@ -16,7 +15,6 @@ use crate::svg::SvgWriter;
pub struct Pipeline {
pub frontend: Box<dyn Frontend>,
pub color_fitters: Vec<Box<dyn ColorFitter>>,
pub fitter: Box<dyn CurveFitter>,
pub compositing: Compositing,
pub optimizers: Vec<Box<dyn OptimizerPass>>,
pub writer: SvgWriter,
@@ -31,9 +29,7 @@ impl Pipeline {
fitter.fit(&mut seg);
}
let mut doc = match self.compositing {
Compositing::Stacked => compose_stacked(&seg, self.fitter.as_ref()),
};
let mut doc = self.compositing.compose(&seg);
for pass in &self.optimizers {
pass.run(&mut doc);
+21 -1
View File
@@ -15,7 +15,7 @@
use std::path::PathBuf;
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode};
use vtracer::{Color, ColorImage, ColorMode, Config, FitMode, Hierarchical};
// --- synthetic image builders ------------------------------------------------
@@ -177,6 +177,26 @@ fn cases() -> Vec<(&'static str, ColorImage, Config)> {
..base()
},
),
// Mosaic (seam-free tessellation): exact pixel and polygon fitters.
(
"disc_mosaic_pixel",
disc(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Pixel,
..base()
},
),
(
"checker_mosaic_polygon",
checker(),
Config {
hierarchical: Hierarchical::Cutout,
mode: FitMode::Polygon,
optimize: 2,
..base()
},
),
]
}
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M16,0H8V8h8V0Z" fill="#EBEBEB"/>
<path d="M48,40H40v8h8V40Z" fill="#141414"/>
<path d="M40,40H32v8h8V40Z" fill="#EBEBEB"/>
<path d="M32,40H24v8h8V40Z" fill="#141414"/>
<path d="M24,40H16v8h8V40Z" fill="#EBEBEB"/>
<path d="M16,40H8v8h8V40Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,40H0v8H8V40Z"/>
<path d="M48,32H40v8h8V32Z"/>
</g>
<path d="M40,32H32v8h8V32Z" fill="#141414"/>
<path d="M32,32H24v8h8V32Z" fill="#EBEBEB"/>
<path d="M24,32H16v8h8V32Z" fill="#141414"/>
<path d="M16,32H8v8h8V32Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,32H0v8H8V32Z"/>
<path d="M48,24H40v8h8V24Z"/>
</g>
<path d="M40,24H32v8h8V24Z" fill="#EBEBEB"/>
<path d="M32,24H24v8h8V24Z" fill="#141414"/>
<path d="M24,24H16v8h8V24Z" fill="#EBEBEB"/>
<path d="M16,24H8v8h8V24Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,24H0v8H8V24Z"/>
<path d="M48,16H40v8h8V16Z"/>
</g>
<path d="M40,16H32v8h8V16Z" fill="#141414"/>
<path d="M32,16H24v8h8V16Z" fill="#EBEBEB"/>
<path d="M24,16H16v8h8V16Z" fill="#141414"/>
<path d="M16,16H8v8h8V16Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M8,16H0v8H8V16Z"/>
<path d="M48,8H40v8h8V8Z"/>
</g>
<path d="M40,8H32v8h8V8Z" fill="#EBEBEB"/>
<path d="M32,8H24v8h8V8Z" fill="#141414"/>
<path d="M24,8H16v8h8V8Z" fill="#EBEBEB"/>
<path d="M16,8H8v8h8V8Z" fill="#141414"/>
<g fill="#EBEBEB">
<path d="M8,8H0v8H8V8Z"/>
<path d="M48,8V0H40V8h8Z"/>
</g>
<path d="M40,0H32V8h8V0Z" fill="#141414"/>
<path d="M32,0H24V8h8V0Z" fill="#EBEBEB"/>
<g fill="#141414">
<path d="M24,0H16V8h8V0Z"/>
<path d="M8,8V0H0V8H8Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 1.0.0-alpha.1 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="48" height="48">
<path d="M0,0L0,48l48,0L48,0L0,0ZM24,8l1,0l0,1l5,0l0,1l2,0l0,1l2,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,2l1,0l0,2l1,0l0,5l1,0l0,1l-1,0l0,5l-1,0l0,2l-1,0l0,2l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-2,0l0,1l-2,0l0,1l-5,0l0,1l-1,0l0-1l-5,0l0-1l-2,0l0-1l-2,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-2l-1,0l0-2L9,30l0-5L8,25l0-1l1,0l0-5l1,0l0-2l1,0l0-2l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l2,0l0-1l2,0l0-1l5,0l0-1Z" fill="#F0F0F0"/>
<path d="M24,8l0,1L19,9l0,1l-2,0l0,1l-2,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,1l-1,0l0,2l-1,0l0,2L9,19l0,5L8,24l0,1l1,0l0,5l1,0l0,2l1,0l0,2l1,0l0,1l1,0l0,1l1,0l0,1l1,0l0,1l2,0l0,1l2,0l0,1l5,0l0,1l1,0l0-1l5,0l0-1l2,0l0-1l2,0l0-1l1,0l0-1l1,0l0-1l1,0l0-1l1,0l0-2l1,0l0-2l1,0l0-5l1,0l0-1l-1,0l0-5l-1,0l0-2l-1,0l0-2l-1,0l0-1l-1,0l0-1l-1,0l0-1l-1,0l0-1l-2,0l0-1l-2,0l0-1L25,9l0-1L24,8Z" fill="#C83C3C"/>
</svg>

After

Width:  |  Height:  |  Size: 985 B

+4 -3
View File
@@ -78,11 +78,12 @@ fn optimize_levels_shrink_or_match() {
}
#[test]
fn cutout_is_reported_unsupported() {
fn mosaic_cutout_produces_svg() {
let img = two_band_image(32);
let config = Config {
hierarchical: Hierarchical::Cutout,
..Config::default()
};
let err = config.build().err().expect("cutout should be unsupported");
assert!(err.to_string().contains("mosaic"));
let svg = config.build().unwrap().to_svg(&img).unwrap();
assert_valid_svg(&svg);
}