This stage also exists as a plain-language page with diagrams.
Stage 10 — Symmetry
Detects the exact mirror symmetry a hand-drawn logo's label map carries, and — after every stage that could break a tie has run — restores it, by averaging each paired boundary point back into exact agreement.
Source: crates/inkvec-trace/src/symmetry.rs (467 lines)
Entry points: fn detect() (symmetry.rs:253, stage mark "symmetry_detect", lib.rs:457-458)
and fn enforce() (symmetry.rs:285, stage mark "symmetry", lib.rs:494-495)
Pipeline position: detect runs immediately after build_map, before refine_subpix
(lib.rs:453-461); enforce runs last of all, after decode (lib.rs:489-495)
What problem this solves
A large fraction of real logos are mirror-symmetric by construction, and the artist's
symmetry is exact: the module doc states that rendering one such icon and comparing it
with its own mirror gives a residual "around 1e-5, which is floating point and nothing
else" (symmetry.rs:4-5). Before this stage existed, the tracer's own output came back
"around 4e-3, two to four hundred times worse, and it is the kind of error a person sees
immediately and a colour metric barely registers — one bar of a three-bar glyph a tenth
of a pixel wider than the other two." (symmetry.rs:5-8)
Crucially, the module doc establishes that none of that asymmetry is present in the
evidence: "Measured through the pipeline, the input raster is symmetric to 1e-6 and
the label map is symmetric to exactly zero." (symmetry.rs:10-11) Every bit of the
error is introduced downstream, by ties broken arbitrarily wherever the code happens to
walk one way rather than another — "which pixel a boundary piece exactly on a pixel
border is assigned to, which direction a chain is traversed, which of two equal
candidates a search reaches first." (symmetry.rs:12-14) Those are genuine coin flips,
present throughout a pipeline this size, and the doc comment rejects fixing them
individually: "the stage responsible differs from icon to icon." (symmetry.rs:15-16)
So symmetry is enforced structurally, as a dedicated pass, rather than defended stage by stage.
Inputs and outputs
pub fn detect(map: &PlanarMap, labels: &[u16], ink: &[usize]) -> Symmetry
ink[label] maps a label to a comparable "ink identity" — necessary because face ids are
per-connected-component, and a symmetric shape's two mirrored halves are never the same
component; comparing face ids directly would reject every real symmetry there is
(symmetry.rs:140-142). In the pipeline this is face_color — the palette entry each
face was cut from (lib.rs:457).
pub struct Symmetry { pub mirrors: Vec<Mirror>, partner: Vec<Vec<Option<Pairing>>>, // partner[m][e]: where edge e reflects under mirrors[m] }
pub fn enforce(map: &mut PlanarMap, sym: &Symmetry) -> usize // returns points moved
enforce mutates map in place, replacing each boundary point that has a partner with
the midpoint of itself and its mirrored partner point.
How it works
Mirror: only the exact centre is a candidate
pub enum Mirror { V(i64), // pixel x -> k - x H(i64), // pixel y -> k - y }
Only integer k can be a symmetry of a pixel grid, which is what keeps the test exact —
the axis then falls either on a pixel centre (k even) or the border between two (k
odd), and every pixel has a partner (symmetry.rs:41-45).
mirrors_of (symmetry.rs:143-178) does not sweep every candidate k. A short argument
in the comment shows only the exact image centre can possibly hold: treating an
out-of-bounds pixel as None and an in-bounds one as Some, "for every x both x and k-x
must be inside: x=0 needs kk=h-1 for the horizontal axis (symmetry.rs:166-170). This is recorded as
an optimisation, made centre-only for speed: the loop that used to try every k cost
"1.1s of a 4s trace at 2048px" because "each off-centre horizontal candidate matched white
margin rows against white margin rows before it could fail," i.e. O(h^2 w)
(symmetry.rs:169-170).
mirrors_of tests candidacy pixel-by-pixel: at(x, y) != at(mx, my) for any pixel fails
the whole mirror (symmetry.rs:153-163) — no tolerance, no threshold. The module doc is
explicit that this exactness is a feature, not an oversight: "a mirror is admitted only
when every pixel maps to a pixel of the same ink. That test cannot be fooled into
snapping a logo the artist drew asymmetric, which is the failure that would matter."
(symmetry.rs:19-21)
pair_edges: matching boundaries by their points, not their endpoints
A closed boundary (a ring) has no meaningful endpoint — the extractor "starts its walk at
whichever crack it reached first, and that choice is not mirror-equivariant"
(symmetry.rs:183-185). So a ring and its reflection agree only up to an unknown
rotation offset and an unknown direction, and pair_edges (symmetry.rs:188-246) has to
recover both:
- Shortlist candidate partners by point count (
by_len,symmetry.rs:194-197). - For each candidate, find where the reflected first point of
elands in the candidate's point list (all matching positions for a closed ring; only the two ends for an open one, since an open boundary can only meet its partner end to end,symmetry.rs:213-219). - Try both directions (
rev in [false, true]) from that offset, and accept the first full point-by-point match (symmetry.rs:220-241).
This runs on lattice points from stage 06's extractor, where "every coordinate is an
exact half-integer, so the comparison is exact and needs no tolerance" (symmetry.rs:
186-187) — the same discipline as the label-map test in mirrors_of. detect returns
an empty Symmetry unless every edge finds a partner (or is its own partner) —
"a mirror that holds on the pixels but not on the extracted boundaries would be a bug, and
acting on half of one would be worse than ignoring it." (symmetry.rs:250-252)
An edge can pair with itself (Pairing.edge == e) — self_mirrors (symmetry.rs:
110-122) reports which mirrors carry an edge onto itself, needed because "a shape lying
across the axis is its own reflection, so it never gets a partner to copy from and has to
be made symmetric in its own right." (symmetry.rs:112-113) — see centre_primitive
below.
enforce: averaging, not copying
let q = m.point(other[pr.index(i, n)]); let mid = Point::new(0.5 * (e.points[i].x + q.x), 0.5 * (e.points[i].y + q.y)); e.points[i] = mid;
(symmetry.rs:300-305). Every paired point is replaced with the midpoint of itself and
its mirrored partner's reflection. The doc comment states why averaging is chosen over
letting one side win: "the two sides carry the same evidence and disagree only by
whatever tie was broken between them, so the midpoint is the estimate both of them
support." (symmetry.rs:282-284)
Why detection happens early and enforcement happens last
detect is run once, immediately after build_map, "on the lattice the extractor
produced, where the comparison is exact" (lib.rs:455-456) — this has to happen before
any sub-pixel refinement moves points off that exact lattice, or the point-identity
matching in pair_edges would need a tolerance instead of exact equality.
enforce is deferred to the very end of trace_color_full_with_alpha, run "once every
stage that can break a tie has had its turn" (lib.rs:456) — after refine_subpixel,
refine_junctions, boundary_opt::optimise, and decode::decode_faces have all had a
chance to nudge points asymmetrically. The comment at the call site makes the intent
explicit: "The label map is exactly symmetric whenever the artist's drawing was, and every
stage above breaks that symmetry a little by breaking ties. Put it back." (lib.rs:
492-493)
The interaction hazard: a stage between detect and enforce can be undone
Because enforce unconditionally averages each paired point with its partner's
reflection, any stage that runs between detect and enforce and treats a symmetric
pair asymmetrically has its asymmetric work erased at the enforce call — the two
sides are forced back to agreement regardless of which one, if either, was "more right."
This is implicit in enforce's mechanics (it has no notion of which side to trust more
than the other) rather than stated as a warning anywhere in symmetry.rs, but it is the
direct consequence of the ordering the pipeline comment describes, and it means a stage
inserted between the two calls needs to either respect the symmetry itself or accept that
enforce will flatten whatever it did asymmetrically.
What enforce does not fix: the fit itself
The module doc is explicit that enforce only makes the geometry symmetric, not the
downstream fit: "Two mirror-paired boundaries with exactly mirrored points can still be
fitted differently, because the dynamic program walks a chain in one direction and its
partner in the other." (symmetry.rs:25-27) That is handled separately, in the CLI's
fitting stage, by fitting only one edge of each mirrored pair and reflecting the result
onto its partner — both exact and half the work (symmetry.rs:28-29; see mirror_of,
symmetry.rs:126-135, which finds the earlier-indexed partner of a given edge so that
following the map always terminates).
Reflecting fitted geometry: reflect_path, reflect_primitive, centre_primitive
Three further functions support the fitting-side half of the symmetry story:
reflect_path(symmetry.rs:317-342) reflects a fittedFittedPath— lines and cubics transform point-by-point; a circular arc keeps its radius and large-arc flag but flips its sweep, "same circle, same side of the chord, the other way round the plane" (symmetry.rs:315-316).reflect_primitive(symmetry.rs:347-366) reflects a fitted circle, ellipse, or rounded rectangle; only an ellipse's axis angle needs adjustment, "because a mirror negates the angle it makes with the mirror's own axis" (symmetry.rs:345-346).centre_primitive(symmetry.rs:375-405) is for a primitive that is its own reflection (viaself_mirrors) — being symmetric is then one exact equation on a couple of its numbers (its centre lies on the axis; an ellipse's angle snaps to whichever right-angle-multiple it is already nearer). The doc comment gives the motivating case directly: "This is where 'make the circle round' actually happens: three bars of a glyph come back 21.3, 21.4 and 21.3 pixels wide because each was fitted alone, and the middle one straddles the axis." (symmetry.rs:372-374)
Constants and thresholds
There are none in the numeric-threshold sense. Every test in this module is exact
equality on integer pixel coordinates or exact half-integer lattice points — detect
either finds a mirror that holds on every pixel or it does not exist at all
(symmetry.rs:19-21, 156-163), and pair_edges either finds an exact point-for-point
match or the whole mirror is discarded (symmetry.rs:243-245, 250-252). This is a
deliberate design choice recorded in the module doc, not an omission — see "What problem
this solves" above.
Failure modes and edge cases
- A symmetric-looking icon with even one pixel of genuine hand-drawn asymmetry finds no
mirror at all, by design —
mirrors_of's per-pixel test has no tolerance (symmetry.rs:153-163), and this is exactly the intended behaviour (see the module doc quote above about not "snapping a logo the artist drew asymmetric"). - A mirror that holds on pixels but not on extracted boundaries is treated as a bug and
silently dropped, per-mirror, in
detect(symmetry.rs:266-276): each candidate mirror is paired independently, and only mirrors whosepair_edgessucceeds are kept in the returnedSymmetry(symmetry.rs:272-275) — the other candidate mirrors are simply not present in the result, with no error surfaced to the caller. - Only two mirror axes are ever considered — a single vertical and a single horizontal
candidate at the exact image centre (
symmetry.rs:171-176). A diagonal mirror, a 4-fold rotational symmetry, or an off-centre local symmetry (e.g. within one glyph of a wordmark rather than across the whole canvas) is out of scope for this stage entirely. - The ordering hazard described above (a stage between
detectandenforceoverwritten byenforce) is a live property of the current pipeline (boundary_opt,decodeboth run in that window,lib.rs:465-489) and is not defended against by any assertion; it is simply accepted as the intended behaviour, per the "Put it back" comment at theenforcecall site.
Environment overrides
INKVEC_TIMING (checked as std::env::var_os) turns on per-call timing output inside
detect for mirrors_of and pair_edges (symmetry.rs:260, 263-264, 267, 269-271).
No other stage-specific overrides exist in this file.
Tests as specification
mod tests (symmetry.rs:407-467) exercises the two central guarantees concretely, on a
synthetic three-bar glyph built to be exactly mirror-symmetric (bars, symmetry.rs:
414-423):
finds_the_mirror_and_pairs_the_boundaries(symmetry.rs:425-433): for a 16-wide image, confirms the detected axis isMirror::V(15)(pixelxmaps to15 - x, so the axis sits between columns 7 and 8) and thatsymis non-empty.averages_a_nudge_back_out(symmetry.rs:435-466): pushes every point of one boundary 0.1px to the right, then callsenforce, and asserts every paired point afterwards is an exact reflection of its partner (dist < 1e-9) — a direct, minimal demonstration that averaging restores exact agreement regardless of which side was perturbed, and that edges without a partner are left untouched (map.edges.len() == before.len(),symmetry.rs:465).
Open questions
- The interaction hazard between
detectandenforce(any intervening stage's asymmetric adjustment being erased) is a real property of the pipeline but is not documented as a warning anywhere insymmetry.rsitself, nor guarded by any test that specifically inserts an asymmetric perturbation between the two calls and confirms it is cleanly undone rather than leaving, say, mismatched point counts. mirrors_ofonly ever tests the exact-centre vertical and horizontal axes, and the file itself explains why for the off-centre case: an axis anywhere other thanw - 1orh - 1cannot satisfy the full-image reflection check, since a pixel inside the image has no counterpart once its mirror falls outside it. An earlier version looped over every candidatekand paid for it — 1.1s of a 4s trace at 2048px, matching white margin rows against white margin rows before failing — for a check that could only ever hold at the centre, so the loop was replaced with the direct computation (symmetry.rs:174-179). Diagonal symmetry is a different matter:Mirrorhas onlyVandHvariants (symmetry.rs:52-57), so a diagonal axis has no representation in the type at all, and neithersymmetry.rsnordocs/DESIGN.mdstates whether omitting it was a deliberate scope decision or simply the first version implemented — that remains Unverified.- No constant in this module lacks a stated derivation, which is unusual among the stages in this pipeline — the entire design rests on exactness rather than tuned thresholds, by the same reasoning documented in stage 06.