Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better shape reuse identification #103

Merged
merged 15 commits into from
Nov 11, 2020
13 changes: 10 additions & 3 deletions src/picosvg/geometric_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
import math
from typing import NamedTuple, Optional, Union

_DEFAULT_ALMOST_EQUAL_TOLERENCE = 1e-9

_DEFAULT_ALMOST_EQUAL_TOLERANCE = 1e-9
_PointOrVec = Union["Point", "Vector"]


def almost_equal(c1, c2, tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE) -> bool:
return abs(c1 - c2) <= tolerance


class Point(NamedTuple):
x: float = 0
y: float = 0
Expand Down Expand Up @@ -51,9 +56,11 @@ def round(self, digits: int) -> "Point":
return Point(round(self.x, digits), round(self.y, digits))

def almost_equals(
self, other: "Point", tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE
self, other: "Point", tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE
) -> bool:
return abs(self.x - other.x) <= tolerence and abs(self.y - other.y) <= tolerence
return almost_equal(self.x, other.x, tolerance) and almost_equal(
self.y, other.y, tolerance
)


class Vector(NamedTuple):
Expand Down
5 changes: 3 additions & 2 deletions src/picosvg/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,9 +765,10 @@ def _apply_gradient_translation(self, inplace=False):
x_prime = (r1 - c * y_prime) / a

# sanity check: a`(x`, y`) should be a(x, y)
p = affine.map_point((x, y))
# all our float brutality damages points; low tolerance sanity checks!
p = Point(r1, r2)
p_prime = affine_prime.map_point((x_prime, y_prime))
assert p.almost_equals(p_prime)
assert p.almost_equals(p_prime, tolerance=1e-1), f"{p} != {p_prime}"

el.attrib[x_attr] = ntos(round(x_prime, _GRADIENT_TRANSFORM_NDIGITS))
el.attrib[y_attr] = ntos(round(y_prime, _GRADIENT_TRANSFORM_NDIGITS))
Expand Down
2 changes: 1 addition & 1 deletion src/picosvg/svg_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def parse_css_declarations(
output: MutableMapping[str, Any],
property_names: Optional[Container[str]] = None,
) -> str:
""" Parse CSS declaration list into {property: value} dict.
"""Parse CSS declaration list into {property: value} dict.

Args:
style: CSS declaration list without the enclosing braces,
Expand Down
8 changes: 4 additions & 4 deletions src/picosvg/svg_path_iter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ def _explode_cmd(args_per_cmd, cmd, args):
def parse_svg_path(svg_path: str, exploded=False):
"""Parses an svg path.

Exploded means when params repeat each the command is reported as
if multiplied. For example "M1,1 2,2 3,3" would report as three
separate steps when exploded.
Exploded means when params repeat each the command is reported as
if multiplied. For example "M1,1 2,2 3,3" would report as three
separate steps when exploded.

Yields tuples of (cmd, (args))."""
Yields tuples of (cmd, (args))."""
command_tuples = []
parts = _CMD_RE.split(svg_path)[1:]
for i in range(0, len(parts), 2):
Expand Down
224 changes: 199 additions & 25 deletions src/picosvg/svg_reuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,59 +14,233 @@

# Functions meant to help discern if shapes are the same or not.

import copy
import dataclasses
from itertools import islice
from math import atan2
from picosvg.geometric_types import Vector, almost_equal
from picosvg.svg_types import SVGShape, SVGPath
from typing import Optional, Tuple
from typing import Generator, Iterable, Optional, Tuple
from picosvg import svg_meta
from picosvg.svg_transform import Affine2D


# Number of decimal digits to round floats when normalizing or comparing
DEFAULT_TOLERANCE = 9
_SIGNIFICANCE_FACTOR = 5 # Must be at least N x tolerance to be significant
_ROUND_RANGE = range(3, 13) # range of rounds to try


def _first_move(shape: SVGShape) -> Tuple[float, float]:
cmd, args = next(iter(shape.as_path()))
def _first_move(path: SVGPath) -> Tuple[float, float]:
cmd, args = next(iter(path))
if cmd.upper() != "M":
raise ValueError(f"Path for {shape} should start with a move")
raise ValueError(f"Path for {path} should start with a move")
return args


def normalize(shape: SVGShape, tolerance: int = DEFAULT_TOLERANCE) -> SVGShape:
"""Build a version of shape that will compare == to other shapes even if offset.
def _vectors(path: SVGPath) -> Generator[Vector, None, None]:
for cmd, args in path:
x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd)
if cmd.lower() == "z":
yield Vector(0.0, 0.0)
else:
yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]])

Intended use is to normalize multiple shapes to identify opportunity for reuse."""
shape = dataclasses.replace(shape, id="")
x, y = _first_move(shape)
shape = (
shape.as_path().move(-x, -y, inplace=True).round_floats(tolerance, inplace=True)

def _nth_vector(path: SVGPath, n: int) -> Vector:
return next(islice(_vectors(path), n, n + 1))


def _angle(v: Vector) -> float:
# gives the directional angle of vector (unlike acos)
return atan2(v.y, v.x)


def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D:
affine = Affine2D.identity()

# rotate initial to have the same angle as target (may have different magnitude)
angle = _angle(target) - _angle(initial)
affine = Affine2D.identity().rotate(angle)
vec = affine.map_vector(initial)

# scale to target magnitude
s = 0
if vec.norm() != 0:
s = target.norm() / vec.norm()

affine = Affine2D.compose_ltr((affine, Affine2D.identity().scale(s, s)))

return affine


def _first_y(vectors: Iterable[Vector], tolerance: float) -> Optional[Vector]:
tolerance = _SIGNIFICANCE_FACTOR * tolerance
for idx, vec in enumerate(vectors):
if idx > 0 and abs(vec.y) > tolerance:
return vec
return None


# Makes a shape safe for a walk with _affine_callback
def _affine_friendly(shape: SVGShape) -> SVGPath:
path = shape.as_path()
if shape is path:
path = copy.deepcopy(path)
return (
path.relative(inplace=True)
.explicit_lines(inplace=True)
.expand_shorthand(inplace=True)
)
return shape


def affine_between(
s1: SVGShape, s2: SVGShape, tolerance: int = DEFAULT_TOLERANCE
) -> Optional[Affine2D]:
# Transform all coords in an affine-friendly path
def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused):
x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd)
# hard to do things like rotate if we have 1d coords
assert len(x_coord_idxs) == len(y_coord_idxs), f"{cmd}, {args}"

args = list(args) # we'd like to mutate 'em
for x_coord_idx, y_coord_idx in zip(x_coord_idxs, y_coord_idxs):
if cmd == cmd.upper():
# for an absolute cmd allow translation: map_point
new_x, new_y = affine.map_point((args[x_coord_idx], args[y_coord_idx]))
else:
# for a relative coord no translate: map_vector
new_x, new_y = affine.map_vector((args[x_coord_idx], args[y_coord_idx]))

if almost_equal(new_x, 0):
new_x = 0
if almost_equal(new_y, 0):
new_y = 0
args[x_coord_idx] = new_x
args[y_coord_idx] = new_y
return ((cmd, args),)


def normalize(shape: SVGShape, tolerance: float, ndigits: int) -> SVGShape:
rsheeter marked this conversation as resolved.
Show resolved Hide resolved
"""Build a version of shape that will compare == to other shapes even if offset,
scaled, rotated, etc.

Intended use is to normalize multiple shapes to identify opportunity for reuse."""

path = _affine_friendly(dataclasses.replace(shape, id=""))

# Make path relative, with first coord at 0,0
x, y = _first_move(path)
path.move(-x, -y, inplace=True)

# Normlize vector 1 to [1 0]; eliminates rotation and uniform scaling
vec1 = _nth_vector(path, 1) # ignore M 0,0
affine1 = _affine_vec2vec(vec1, Vector(1, 0))
path.walk(lambda *args: _affine_callback(affine1, *args))

# Scale first y movement to 1.0
vecy = _first_y(_vectors(path), tolerance)
if vecy and not almost_equal(vecy.y, 1.0):
affine2 = Affine2D.identity().scale(1, 1 / vecy.y)
path.walk(lambda *args: _affine_callback(affine2, *args))

# TODO: what if shapes are the same but different start point
# TODO: what if shapes are the same but different drawing cmds
# This DOES happen in Noto; extent unclear

path.round_floats(ndigits, inplace=True)
return path


def _apply_affine(affine: Affine2D, s: SVGPath) -> SVGPath:
s_prime = copy.deepcopy(s)
s_prime.walk(lambda *args: _affine_callback(affine, *args))
return s_prime


def _try_affine(affine: Affine2D, s1: SVGPath, s2: SVGPath, tolerance: float):
return _apply_affine(affine, s1).almost_equals(s2, tolerance)


def _round(affine, s1, s2, tolerance):
# TODO bsearch?
for i in _ROUND_RANGE:
rounded = affine.round(i)
if _try_affine(rounded, s1, s2, tolerance):
return rounded
return affine # give up


def affine_between(s1: SVGShape, s2: SVGShape, tolerance: float) -> Optional[Affine2D]:
"""Returns the Affine2D to change s1 into s2 or None if no solution was found.

Implementation starting *very* basic, can improve over time.
Intended use is to call this only when the normalized versions of the shapes
are the same, in which case finding a solution is typical

"""
s1 = dataclasses.replace(s1, id="")
s2 = dataclasses.replace(s2, id="")

if s1.almost_equals(s2, tolerance):
return Affine2D.identity()

s1 = s1.as_path()
s2 = s2.as_path()
s1 = _affine_friendly(s1)
s2 = _affine_friendly(s2)

s1x, s1y = _first_move(s1)
s2x, s2y = _first_move(s2)
dx = s2x - s1x
dy = s2y - s1y

s1.move(dx, dy, inplace=True)
affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y)
if _try_affine(affine, s1, s2, tolerance):
return affine

if s1.almost_equals(s2, tolerance):
return Affine2D.identity().translate(dx, dy)
# Normalize first edge.
# Fixes rotation, x-scale, and uniform scaling.
s1_vec1 = _nth_vector(s1, 1)
s2_vec1 = _nth_vector(s2, 1)

s1_to_origin = Affine2D.identity().translate(-s1x, -s1y)
s2_to_origin = Affine2D.identity().translate(-s2x, -s2y)
s1_vec1_to_s2_vec1 = _affine_vec2vec(s1_vec1, s2_vec1)

# Move to s2 start
origin_to_s2 = Affine2D.identity().translate(s2x, s2y)

affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2))
if _try_affine(affine, s1, s2, tolerance):
return _round(affine, s1, s2, tolerance)

# Could be non-uniform scaling and/or mirroring
# Scale first y movement (after matching up vec1) to match
rsheeter marked this conversation as resolved.
Show resolved Hide resolved

# Rotate first edge to lie on x axis
s2_vec1_angle = _angle(s2_vec1)
rotate_s2vec1_onto_x = Affine2D.identity().rotate(-s2_vec1_angle)
rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle)

affine = Affine2D.compose_ltr(
(s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x)
)
s1_prime = _apply_affine(affine, s1)

affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x))
s2_prime = _apply_affine(affine, s2)

s1_vecy = _first_y(_vectors(s1_prime), tolerance)
s2_vecy = _first_y(_vectors(s2_prime), tolerance)

if s1_vecy and s2_vecy:
affine = Affine2D.compose_ltr(
(
s1_to_origin,
s1_vec1_to_s2_vec1,
# lie vec1 along x axis
rotate_s2vec1_onto_x,
# scale first y-vectors to match; x-parts should already match
Affine2D.identity().scale(1.0, s2_vecy.y / s1_vecy.y),
# restore the rotation we removed
rotate_s2vec1_off_x,
# drop into final position
origin_to_s2,
)
)
if _try_affine(affine, s1, s2, tolerance):
return _round(affine, s1, s2, tolerance)

# If we still aren't the same give up
return None
23 changes: 21 additions & 2 deletions src/picosvg/svg_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
Focuses on converting to a sequence of affine matrices.
"""
import collections
from functools import reduce
from math import cos, sin, radians, tan
import re
from typing import NamedTuple, Tuple
from typing import NamedTuple, Sequence, Tuple
from sys import float_info
from picosvg.geometric_types import Point, Rect, Vector

Expand Down Expand Up @@ -48,6 +49,10 @@ class Affine2D(NamedTuple):
e: float
f: float

@staticmethod
def flip_y():
return Affine2D._flip_y

@staticmethod
def identity():
return Affine2D._identity
Expand Down Expand Up @@ -145,9 +150,22 @@ def map_vector(self, vec: Tuple[float, float]) -> Vector:
x, y = vec
return Vector(self.a * x + self.c * y, self.b * x + self.d * y)

@classmethod
def compose_ltr(cls, affines: Sequence["Affine2D"]) -> "Affine2D":
"""Creates merged transform equivalent to applying transforms left-to-right order.

Affines apply like functions - f(g(x)) - so we merge them in reverse order.
"""
return reduce(
lambda acc, a: cls.product(a, acc), reversed(affines), cls.identity()
)

def round(self, digits: int) -> "Affine2D":
return Affine2D(*(round(v, digits) for v in self))

@classmethod
def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D":
""" Return Affine2D set to scale and translate src Rect to dst Rect.
"""Return Affine2D set to scale and translate src Rect to dst Rect.
The mapping completely fills dst, it does not preserve aspect ratio.
"""
if src.empty():
Expand All @@ -163,6 +181,7 @@ def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D":

Affine2D._identity = Affine2D(1, 0, 0, 1, 0, 0)
Affine2D._degnerate = Affine2D(0, 0, 0, 0, 0, 0)
Affine2D._flip_y = Affine2D(1, 0, 0, -1, 0, 0)


def _fix_rotate(args):
Expand Down
Loading