weavingspace.symmetry

  1#!/usr/bin/env python
  2# coding: utf-8
  3
  4import inspect
  5from typing import Iterable
  6from typing import Any
  7from typing import Union
  8from collections import namedtuple
  9from dataclasses import dataclass
 10import string
 11import copy
 12
 13import numpy as np
 14import matplotlib.pyplot as pyplot
 15import io
 16import PIL
 17
 18import shapely.geometry as geom
 19import shapely.affinity as affine
 20import shapely.ops
 21import geopandas as gpd
 22
 23import weavingspace.tiling_utils as tiling_utils
 24
 25
 26class KMP_Matcher:
 27  """Class to find matching subsequences in a sequence."""
 28
 29  sequence: Iterable
 30  """Iterable in which subsequences are to be found."""
 31
 32  def __init__(self, sequence:Iterable):
 33    self.sequence = sequence
 34
 35  def find_matches(self, pat:Iterable[tuple[float]]) -> list[int]:
 36    """ Implements Knuth-Morris-Pratt string pattern matching algorithm. See:
 37    https://en.wikipedia.org/wiki/Knuth–Morris–Pratt_algorithm which provides
 38    detailed pseudo-code on which this code is directly based. See also:
 39    
 40    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in 
 41    strings. SIAM Journal on Computing 6(2): 323–350. doi: 10.1137/0206024.
 42
 43    This implementation expects sequences of tuples of floats, although in 
 44    principle any objects could be contained in the Iterables.
 45
 46    Args:
 47      pat (Iterable[tuple[float]]): The sequence to match.
 48
 49    Returns:
 50      Iterable[int]: _description_
 51    """
 52    j, k, = 0, 0
 53    finds = []
 54    table = self._get_table(pat)
 55    while j < len(self.sequence):
 56      if self._equal_tuples(pat[k], self.sequence[j]):
 57        j = j + 1
 58        k = k + 1
 59        if k == len(pat):
 60          finds.append(j - k)
 61          k = table[k]
 62      else:
 63        k = table[k]
 64        if k < 0:
 65          j = j + 1
 66          k = k + 1
 67    return finds
 68
 69  def _get_table(self, pattern:Iterable) -> Iterable[int]:
 70    """Returns the 'offsets' table used by KMP pattern matching algorithm as
 71    required by self.find_matches, based on the supplied pattern.
 72
 73    Args:
 74      pattern (Iterable): The pattern to set up the table for.
 75      equals (function, optional): A function to use to test for equality of
 76        elements in patterns. Defaults to _equal_tuples().
 77
 78    Returns:
 79        Iterable[int]: _description_
 80    """
 81    pos = 1
 82    cnd = 0
 83    T = {0: -1}
 84    while pos < len(pattern):
 85      if np.allclose(pattern[pos], pattern[cnd]):
 86        T[pos] = T[cnd]
 87      else:
 88        T[pos] = cnd
 89        while cnd >= 0 and not self._equal_tuples(pattern[pos], pattern[cnd]):
 90          cnd = T[cnd]
 91      pos = pos + 1
 92      cnd = cnd + 1
 93    T[pos] = cnd
 94    return tuple(T.values())
 95
 96  def _equal_tuples(self, t1:Iterable[float], t2:Iterable[float]) -> bool:
 97    """Tests for near equality of two iterables of floats using numpy
 98    allclose. Wrapped so as to allow find_matches function to take 
 99    alternative equality tests as input.
100    """
101    return np.allclose(t1, t2, atol = tiling_utils.RESOLUTION * 10)
102
103  def _round_tuple(self, t:Iterable[float], digits:int = 3) -> Iterable[float]:
104    """Convenience function to round all members of an interable. Used for 
105    display purposes."""
106    return tuple([np.round(x, digits) for x in t])
107
108class Transform:
109  """Class to store details of a transform and draw it."""
110  transform_type: str
111  """Type of transform, 'rotation', 'reflection', 'translation' or 'identity'."""
112  angle: float
113  """Angle of rotation (degrees)."""
114  centre: geom.Point
115  """Centre of the transformation."""
116  translation: tuple[float] 
117  """X and Y coordinates shifts of a translation transform. A glide reflection
118  may also include this."""
119  transform: tuple[float]
120  """Six element tuple for the transform in shapely.transform format. See
121  https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations and
122  methods in `weavingspace.tiling_utils`."""
123  offset: int
124
125  def __init__(self, transform_type:str, angle:float, centre:geom.Point, 
126               translation: tuple[float], transform:tuple[float]):
127    self.transform_type = transform_type
128    self.angle = angle
129    self.centre = centre
130    self.translation = translation
131    self.transform = transform
132
133  def __str__(self) -> str:
134    if self.transform_type == "rotation":
135      return f"{self.transform_type} {self.angle:.1f}° POINT ({self.centre.x:.1f} {self.centre.y:.1f}) {tuple([np.round(x, 3) for x in self.transform])}"
136    elif self.transform_type == "reflection":
137      return f"{self.transform_type} {self.angle:.1f}° {tuple([np.round(x, 3) for x in self.transform])}"
138    else:
139      return f"{self.transform_type} {tuple([np.round(x, 3) for x in self.transform])}"
140
141  def __repr__(self) -> str:
142    return str(self)
143
144  def apply(self, geometry:Any) -> Any:
145    """Applies this transform to supplied shapely geometry and returns result.
146
147    Args:
148      geometry (Any): a shapely geometry to transform.
149    
150    Returns:
151      Any: the resulting transformed shapely geometry.
152    """
153    return affine.affine_transform(geometry, self.transform)
154
155  def draw(self, ax:pyplot.axes, **kwargs) -> pyplot.Axes:
156    """Draws this transform on the supplied axes. Arguments specific to each
157    transform type are supplied as **kwargs and are documented in 
158    `draw_rotation`, `draw_reflection`, and `draw_translation`.
159
160    Args:
161        ax (pyplot.axes): the axes on which to draw the transform.
162
163    Returns:
164        pyplot.Axes: the axes with the rendering of this transform added.
165    """
166    if self.transform_type == "rotation":
167      rotn_args = list(inspect.signature(self.draw_rotation).parameters)
168      rotn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in rotn_args}
169      return self.draw_rotation(ax = ax, **rotn_dict)
170    elif self.transform_type == "reflection":
171      refn_args = list(inspect.signature(self.draw_reflection).parameters)
172      refn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in refn_args}
173      return self.draw_reflection(ax = ax, **refn_dict)
174    if self.transform_type == "translation":
175      trans_args = list(inspect.signature(self.draw_translation).parameters)
176      trans_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in trans_args}
177      return self.draw_translation(ax = ax, **trans_dict)
178    elif self.transform_type == "identity":
179      w = ax.get_window_extent().width
180      return ax.set_title(f"{self.transform_type}", fontsize = w / 20)
181
182  def draw_rotation(self, ax:pyplot.Axes, 
183                    radius = 200, add_title = True) -> pyplot.Axes:
184    x, y = self.centre.x, self.centre.y
185    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
186    arc = geom.LineString([
187      geom.Point(x + radius * np.cos(np.radians(a)),
188                  y + radius * np.sin(np.radians(a)))
189      for a in np.linspace(0, self.angle, 50)])
190    gpd.GeoSeries([self.centre]).plot(
191      ax = ax, color = "r", markersize = 4, zorder = 5)
192    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "r", lw = .5)
193    if add_title:
194      w = ax.get_window_extent().width
195      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
196                   fontsize = w / 20)
197    return ax
198
199  def draw_reflection(self, ax:pyplot.Axes, w = 5, 
200                      mirror_length = 100, add_title = True) -> pyplot.Axes:
201    x, y = self.centre.x, self.centre.y
202    dx, dy = self.translation
203    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2) 
204    mirror = geom.LineString([
205      (x - mirror_length / 2 * np.cos(np.radians(self.angle)), 
206        y - mirror_length / 2 * np.sin(np.radians(self.angle))),
207      (x + mirror_length / 2 * np.cos(np.radians(self.angle)), 
208        y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
209    gpd.GeoSeries([mirror]).plot(
210      ax = ax, color = "r", lw = 1, ls = "dashdot", zorder = 5)
211    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
212    if not no_slide:
213      pyplot.arrow(
214        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
215        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
216    if add_title:
217      w = ax.get_window_extent().width
218      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
219                   fontsize = w / 20)
220    return ax
221
222  def draw_translation(self, ax:pyplot.Axes, c:geom.Point, 
223                       w:float = 5, add_title = True) -> pyplot.Axes:
224    gpd.GeoSeries([c]).plot(ax = ax, color = "b")
225    pyplot.arrow(c.x, c.y, self.translation[0], self.translation[1], lw = 0.5,
226                width = w, fc = "b", ec = None, head_width = w * 6, zorder = 5)
227    if add_title:
228      w = ax.get_window_extent().width
229      rounded_tr = tuple([np.round(x, 1) for x in self.translation])
230      ax.set_title(f"{self.transform_type} {rounded_tr}",
231                   fontsize = w / 20)
232    return ax
233
234
235@dataclass
236class Symmetries():
237  """Class to identify and store the symmetries of a supplied shapely.Polygon
238  as a list of `Transform` objects.
239  """
240  polygon:geom.Polygon = None
241  """Polygon from which these symmetries are derived."""
242  matcher:KMP_Matcher = None
243  """the subsequence matcher used in symmetry detection."""
244  n:int = None
245  """number of vertices of the polygon."""
246  p_code:list[tuple[float]] = None
247  """the encoding of the polygon as sequence of length, angle pairs."""
248  p_code_r:list[tuple[float]] = None
249  """the reversed encoding used to detect reflection symmetries."""
250  rotation_shifts:list[int] = None
251  """list of number of 2pi/n rotation symmetries."""
252  reflection_shifts:list[int] = None
253  """list of pi/n relection angle symmetries."""
254  symmetries:list[Transform] = None
255  """list of Transform objects with more complete information."""
256  symmetry_group:str = None
257  """the code denoting the symmetry group"""
258
259  def __init__(self, polygon:geom.Polygon):
260    self.polygon = polygon
261    self.n = shapely.count_coordinates(self.polygon) - 1
262    self.p_code = self._get_polygon_code(self.polygon)
263    self.p_code_r = self._get_polygon_code(self.polygon, mirrored = True)
264    self.matcher = KMP_Matcher(self.p_code + self.p_code[:-1])
265    self.symmetries = self.get_symmetries()
266    self.symmetry_group = self.get_symmetry_group_code()
267
268  def _get_polygon_code(self, p:geom.Polygon, 
269                        mirrored = False) -> list[tuple[float]]:
270    r"""Returns a list of length-angle pairs to uniquely encode a polygon
271    shape (up to scale). The alignment of lengths and angles is important
272    and symmetry detection is sensitively dependent on it...
273
274    The forward (i.e. unmirrored) polygon pairs are of edge lengths and the 
275    angle between each edge and its successor going CW around the polygon. 
276    
277           A[i] ----L[i]---- A (i+1)
278          /                   \
279         /                     \
280                             
281    i.e. edge i is between angles i and i + 1, and angle i is between edge
282    i-1 and i. The unmirrored encoding pairs length[i] with angle[i+1].
283    
284    The mirrored encoding pairs matched indexes of lengths and angles in the
285    original polygon. This means the angle is still the one between and edge 
286    and its successor but proceeding CCW around the polygon.
287    
288    See in particular for clarification.
289    
290    Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and 
291    Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. 
292    North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
293    
294    Args:
295      p (geom.Polygon): the polygon to encode
296      mirrored (bool, optional): if true encoding will be in CCC order. 
297        Defaults to False.
298
299    Returns:
300      list[tuple[float]]: _description_
301    """
302    # get edge lengths and angles
303    lengths = tiling_utils.get_side_lengths(p)
304    raw_angles = tiling_utils.get_interior_angles(p)
305    if mirrored:
306      lengths_r = list(reversed(lengths))
307      angles_r = list(reversed(raw_angles))
308      return list(zip(lengths_r, angles_r))
309    else:
310      angles = raw_angles[1:] + raw_angles[:1]
311      return list(zip(lengths, angles))
312
313  def get_symmetries(self) -> list[Transform]:
314    """Finds rotation and reflection symmetries of the supplied polygon.
315    Based on
316    
317      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and 
318      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. 
319      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
320    
321    and also
322    
323      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry 
324      detection in two and three dimensions. The Visual Computer 1(1): 37-48. 
325      doi: 10.1007/BF01901268.
326    
327    Details in these papers are not unambiguous. This implementation was
328    developed based on them, but with a lot of trial and error to get the index
329    offsets and (especially) retrieval of the reflection axes angles to work.
330
331    Returns:
332      list[Transform]: a list of Transform objects representing the polygon
333        symmetries.
334    """
335    # ==== Rotations ====
336    self.rotation_shifts = self.matcher.find_matches(self.p_code)
337    # ==== Reflections ====
338    self.reflection_shifts = self.matcher.find_matches(self.p_code_r)
339    return self.get_rotations(self.rotation_shifts) + \
340           self.get_reflections(self.reflection_shifts)
341
342  def get_rotations(self, offsets:list[int]) -> list[Transform]:
343    """Gets the rotations associated with this collection of symmetries.
344
345    Returns:
346      list[Transform]: A list of the rotation symmetries associated with this
347        polygon.
348    """
349    c = self.polygon.centroid
350    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets] 
351    rotation_transforms = [
352      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6) 
353      for a in rot_angles]
354    return [Transform("rotation", angle, c, (0, 0), transform)
355            for transform, angle in zip(rotation_transforms, rot_angles)]
356
357  def get_reflections(self, offsets:list[int]) -> list[Transform]:
358    """Gets the reflections associated with this collection of symmetries.
359
360    Returns:
361      list[Transform]: A list of the reflection symmetries associated with this
362        polygon.
363    """
364    # calculate all possible reflection axes - ordering of these is important
365    # for correct picking - don't mess with this code!
366    c = self.polygon.centroid
367    bearings = tiling_utils.get_side_bearings(self.polygon)
368    reflection_axes = []
369    interior_angles = tiling_utils.get_interior_angles(self.polygon)
370    for i in range(self.n):
371      # angle bisectors
372      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
373      # edge_perpendicular bisectors
374      reflection_axes.append(bearings[i] - 90)  # normal to the edge
375    # pick out those where matches occurred
376    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
377    reflection_transforms = [
378      np.round(tiling_utils.get_reflection_transform(a, (c.x, c.y)), 6)
379      for a in ref_angles]
380    return [Transform("reflection", angle, c, (0, 0), transform)
381            for transform, angle in zip(reflection_transforms, ref_angles)]
382
383  def get_symmetry_group_code(self):
384    if len(self.reflection_shifts) == 0:
385      return f"C{len(self.rotation_shifts)}"
386    else:
387      return f"D{len(self.rotation_shifts)}"
388
389  def get_corner_offset(self, poly2:geom.Polygon):
390    s2 = Symmetries(poly2)
391    matches = self.matcher.find_matches(s2.p_code)
392    if len(matches) > 0:
393      return matches[0]
394    matches = self.matcher.find_matches(s2.p_code_r)
395    if len(matches) > 0:
396      return -matches[0]
397    return None
398
399  def get_corner_labels(self) -> dict[str, list[str]]:
400    """Returns all the reorderings of vertex labels corresponding to each
401    symmetry.
402
403    Returns:
404      dict[str, list[str]]: A dictionary with two entries. "rotations" is
405        a list of labels under the rotation symmetries, "reflections" those
406        under the reflection symmetries.
407    """
408    labels = list(string.ascii_letters.upper())[:self.n]
409    return self._get_labels_under_symmetries(labels)
410
411  def get_unique_labels(self, offset:int = 0) -> list[str]:
412    labellings = self.get_corner_labels()
413    labellings = labellings["rotations"] + labellings["reflections"]
414    labellings = ["".join(sorted(x)) for x in zip(*labellings)]
415    n_new_labels = len(set(labellings))
416    letters = list(string.ascii_letters.upper())[offset:offset + n_new_labels]
417    mapping = dict()
418    i = 0
419    for label in labellings:
420      if not label in mapping:
421        mapping[label] = letters[i]
422        i = i + 1
423    return self._get_labels_under_symmetries([mapping[x] for x in labellings])
424  
425  def _get_labels_under_symmetries(
426      self, labels:list[str]) -> dict[str, list[str]]:
427    cycle = labels * 2
428    under_rotation = ["".join(cycle[i:self.n + i]) 
429                      for i in self.rotation_shifts]
430    under_reflection = ["".join(cycle[self.n + i:i:-1]) 
431                        for i in self.reflection_shifts]
432    return {"rotations": under_rotation,
433            "reflections": under_reflection}
434
435  def plot(self, as_image:bool = False, title:str = ""):
436    fig = pyplot.figure()
437    fig.suptitle(title)
438
439    n_subplots = len(self.symmetries)
440    nr = int(np.ceil(np.sqrt(n_subplots)))
441    nc = int(np.ceil(n_subplots / nr))
442    n_plots = 0
443
444    for s in self.symmetries:
445      n_plots = n_plots + 1
446      ax = fig.add_subplot(nr, nc, n_plots)
447      s.plot(ax, self.polygon)
448    
449    if as_image:
450      buf = io.BytesIO()
451      fig.savefig(buf)
452      buf.seek(0)
453      return PIL.Image.open(buf)
454    return ax
455
456
457StraightLine = namedtuple("StraightLine", "A B C")
458"""Named tuple to represent equation of a straight line in standard 
459Ax + By + C = 0 form."""
460
461
462class Shape_Matcher:
463  shape: geom.Polygon
464  s1: Symmetries
465  other: geom.Polygon
466  centre: geom.Point
467  translation: tuple[float]
468  matches: list[Transform]
469  identity_transform: tuple[float] = (1, 0, 0, 1, 0, 0)
470  
471  def __init__(self, shape: geom.Polygon):
472    self.shape = shape
473    self.s1 = Symmetries(shape)
474
475  def get_polygon_matches(self, shape2: geom.Polygon):
476    s2 = Symmetries(shape2)
477    if self.s1.symmetry_group != s2.symmetry_group:
478      # print("No matches")
479      return None
480    else:
481      match_rot, rots = self._get_rotation_matches(s2)
482      match_ref, refs = self._get_reflection_matches(s2)
483      if match_rot and match_ref:
484        # print(f"Rotation and reflection matches found")
485        return rots + refs
486      elif match_rot:
487        # print(f"Only rotation matches found")
488        return rots
489      elif match_ref:
490        # print(f"Only reflection matches found")
491        return refs
492      else:
493        # print (f"No matches found")
494        return None
495
496  def _get_rotation_matches(self, s2:Symmetries):
497    matches = self.s1.matcher.find_matches(s2.p_code)
498    if len(matches) == 0:
499      return False, None
500    else:
501      transforms = []
502      # get lists of polygon corners aligned correctly to measure the angle
503      p1_corners = tiling_utils.get_corners(self.shape, repeat_first = False)
504      p2_corners = tiling_utils.get_corners(s2.polygon, repeat_first = False)
505      for m in matches:
506        a, c = self.get_angle_between_polygons(p1_corners, p2_corners, m)
507        if a == "translation":
508          # print("One of the rotation matches is a translation.")
509          transforms.append(
510            Transform("translation", None, None, c, (1, 0, 0, 1, c[0], c[1])))
511        elif a == "identity":
512          transforms.append(
513            Transform("identity", None, None, c, (1, 0, 0, 1, 0, 0)))
514        elif a is None:
515          continue
516        else:
517          transforms.append(Transform("rotation", a, c, (0, 0),
518            tiling_utils.get_rotation_transform(a, (c.x, c.y))))
519      return True, transforms
520
521  def get_angle_between_polygons(self, corners1:list[geom.Point], 
522      corners2:list[geom.Point], offset:int) -> tuple[Union[float,geom.Point]]:
523    corners2 = corners2[offset:] + corners2[:offset]
524    dists = [p1.distance(p2) for p1, p2 in zip(corners1, corners2)]
525    if all([np.isclose(dists[0], d, atol = 1e-3, rtol = 1e-3) for d in dists]):
526      if np.isclose(dists[0], 0, atol = 1e-3, rtol  = 1e-3):
527        return "identity", (0, 0)
528      else:
529        return "translation", (corners1[0].x - corners2[0].x,
530                               corners1[0].y - corners2[0].y)
531    ordered_dists = sorted([(i, d) for i, d in enumerate(dists)], 
532                           key = lambda x: x[1])
533    AB = ordered_dists[-2:]
534    p1A, p1B = corners1[AB[0][0]], corners1[AB[1][0]]
535    p2A, p2B = corners2[AB[0][0]], corners2[AB[1][0]]
536    perpAA = self.get_straight_line(p1A, p2A, True)
537    perpBB = self.get_straight_line(p1B, p2B, True)
538    centre = self.get_intersection(perpAA, perpBB)
539    if centre is None:
540      angle = None
541    else:
542      angle = -tiling_utils.get_inner_angle(p1A, centre, p2A)
543      if angle < -179:
544        angle = angle + 360
545      elif angle > 180:
546        angle = angle - 360
547    return angle, centre
548
549  def get_straight_line(self, p1:geom.Point, p2:geom.Point, 
550                        perpendicular:bool = False) -> StraightLine:
551    if perpendicular:
552      ls = affine.rotate(geom.LineString([p1, p2]), 90)
553      pts = [p for p in ls.coords]
554      x1, y1 = pts[0]
555      x2, y2 = pts[1]
556    else:
557      x1, y1 = p1.x, p1.y
558      x2, y2 = p2.x, p2.y
559    return StraightLine(y1 - y2, x2 - x1, x1 * y2 - x2 * y1)
560
561  def get_intersection(self, line1:StraightLine, 
562                       line2:StraightLine) -> geom.Point:
563    x_set, y_set = False, False
564    denominator = line1.A * line2.B - line2.A * line1.B
565    if np.isclose(line1.A, 0, atol = 1e-4, rtol = 1e-4):
566      y = -line1.C / line1.B
567      y_set = True
568    elif np.isclose(line2.A, 0, atol = 1e-4, rtol = 1e-4):
569      y = -line2.C / line2.B
570      y_set = True
571    if np.isclose(line1.B, 0, atol = 1e-4, rtol = 1e-4):
572      x = -line1.C / line1.A
573      x_set = True
574    elif np.isclose(line2.B, 0, atol = 1e-4, rtol = 1e-4):
575      x = -line2.C / line2.A
576      x_set = True
577    if np.isclose(denominator, 0, atol = 1e-4, rtol = 1e-4):
578      return None
579    x = x if x_set else (line1.B * line2.C - line2.B * line1.C) / denominator
580    y = y if y_set else (line1.C * line2.A - line2.C * line1.A) / denominator
581    return geom.Point(x, y)
582
583  def _get_reflection_matches(self, s2:Symmetries):
584    ctr1 = self.s1.polygon.centroid
585    ctr2 = s2.polygon.centroid
586    c = ((ctr1.x + ctr2.x) / 2, (ctr1.y + ctr2.y) / 2)
587    matches = self.s1.matcher.find_matches(s2.p_code_r)
588    if len(matches) == 0:
589      return False, None
590    reflections1 = self.s1.get_reflections(matches)
591    reflections2 = s2.get_reflections(matches)
592    angles = [(ref1.angle + ref2.angle) / 2
593              for ref1, ref2 in zip(reflections1, reflections2)]
594    trs = [tiling_utils.get_reflection_transform(angle, c)
595           for angle in angles]
596    ctrs2r = [affine.affine_transform(ctr2, tr) for tr in trs]
597    dxdys = [(ctr1.x - ctr2r.x, ctr1.y - ctr2r.y) for ctr2r in ctrs2r]
598    return True, [Transform(
599      "reflection", angle, geom.Point(c), dxdy,
600      tiling_utils.combine_transforms(
601        [tiling_utils.get_reflection_transform(angle, c), 
602         (1, 0, 0, 1, dxdy[0], dxdy[1])])) 
603      for angle, dxdy in zip(angles, dxdys)]
class KMP_Matcher:
 27class KMP_Matcher:
 28  """Class to find matching subsequences in a sequence."""
 29
 30  sequence: Iterable
 31  """Iterable in which subsequences are to be found."""
 32
 33  def __init__(self, sequence:Iterable):
 34    self.sequence = sequence
 35
 36  def find_matches(self, pat:Iterable[tuple[float]]) -> list[int]:
 37    """ Implements Knuth-Morris-Pratt string pattern matching algorithm. See:
 38    https://en.wikipedia.org/wiki/Knuth–Morris–Pratt_algorithm which provides
 39    detailed pseudo-code on which this code is directly based. See also:
 40    
 41    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in 
 42    strings. SIAM Journal on Computing 6(2): 323–350. doi: 10.1137/0206024.
 43
 44    This implementation expects sequences of tuples of floats, although in 
 45    principle any objects could be contained in the Iterables.
 46
 47    Args:
 48      pat (Iterable[tuple[float]]): The sequence to match.
 49
 50    Returns:
 51      Iterable[int]: _description_
 52    """
 53    j, k, = 0, 0
 54    finds = []
 55    table = self._get_table(pat)
 56    while j < len(self.sequence):
 57      if self._equal_tuples(pat[k], self.sequence[j]):
 58        j = j + 1
 59        k = k + 1
 60        if k == len(pat):
 61          finds.append(j - k)
 62          k = table[k]
 63      else:
 64        k = table[k]
 65        if k < 0:
 66          j = j + 1
 67          k = k + 1
 68    return finds
 69
 70  def _get_table(self, pattern:Iterable) -> Iterable[int]:
 71    """Returns the 'offsets' table used by KMP pattern matching algorithm as
 72    required by self.find_matches, based on the supplied pattern.
 73
 74    Args:
 75      pattern (Iterable): The pattern to set up the table for.
 76      equals (function, optional): A function to use to test for equality of
 77        elements in patterns. Defaults to _equal_tuples().
 78
 79    Returns:
 80        Iterable[int]: _description_
 81    """
 82    pos = 1
 83    cnd = 0
 84    T = {0: -1}
 85    while pos < len(pattern):
 86      if np.allclose(pattern[pos], pattern[cnd]):
 87        T[pos] = T[cnd]
 88      else:
 89        T[pos] = cnd
 90        while cnd >= 0 and not self._equal_tuples(pattern[pos], pattern[cnd]):
 91          cnd = T[cnd]
 92      pos = pos + 1
 93      cnd = cnd + 1
 94    T[pos] = cnd
 95    return tuple(T.values())
 96
 97  def _equal_tuples(self, t1:Iterable[float], t2:Iterable[float]) -> bool:
 98    """Tests for near equality of two iterables of floats using numpy
 99    allclose. Wrapped so as to allow find_matches function to take 
100    alternative equality tests as input.
101    """
102    return np.allclose(t1, t2, atol = tiling_utils.RESOLUTION * 10)
103
104  def _round_tuple(self, t:Iterable[float], digits:int = 3) -> Iterable[float]:
105    """Convenience function to round all members of an interable. Used for 
106    display purposes."""
107    return tuple([np.round(x, digits) for x in t])

Class to find matching subsequences in a sequence.

KMP_Matcher(sequence: Iterable)
33  def __init__(self, sequence:Iterable):
34    self.sequence = sequence
sequence: Iterable

Iterable in which subsequences are to be found.

def find_matches(self, pat: Iterable[tuple[float]]) -> list[int]:
36  def find_matches(self, pat:Iterable[tuple[float]]) -> list[int]:
37    """ Implements Knuth-Morris-Pratt string pattern matching algorithm. See:
38    https://en.wikipedia.org/wiki/Knuth–Morris–Pratt_algorithm which provides
39    detailed pseudo-code on which this code is directly based. See also:
40    
41    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in 
42    strings. SIAM Journal on Computing 6(2): 323–350. doi: 10.1137/0206024.
43
44    This implementation expects sequences of tuples of floats, although in 
45    principle any objects could be contained in the Iterables.
46
47    Args:
48      pat (Iterable[tuple[float]]): The sequence to match.
49
50    Returns:
51      Iterable[int]: _description_
52    """
53    j, k, = 0, 0
54    finds = []
55    table = self._get_table(pat)
56    while j < len(self.sequence):
57      if self._equal_tuples(pat[k], self.sequence[j]):
58        j = j + 1
59        k = k + 1
60        if k == len(pat):
61          finds.append(j - k)
62          k = table[k]
63      else:
64        k = table[k]
65        if k < 0:
66          j = j + 1
67          k = k + 1
68    return finds

Implements Knuth-Morris-Pratt string pattern matching algorithm. See: https://en.wikipedia.org/wiki/Knuth–Morris–Pratt_algorithm which provides detailed pseudo-code on which this code is directly based. See also:

Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern matching in strings. SIAM Journal on Computing 6(2): 323–350. doi: 10.1137/0206024.

This implementation expects sequences of tuples of floats, although in principle any objects could be contained in the Iterables.

Args: pat (Iterable[tuple[float]]): The sequence to match.

Returns: Iterable[int]: _description_

class Transform:
109class Transform:
110  """Class to store details of a transform and draw it."""
111  transform_type: str
112  """Type of transform, 'rotation', 'reflection', 'translation' or 'identity'."""
113  angle: float
114  """Angle of rotation (degrees)."""
115  centre: geom.Point
116  """Centre of the transformation."""
117  translation: tuple[float] 
118  """X and Y coordinates shifts of a translation transform. A glide reflection
119  may also include this."""
120  transform: tuple[float]
121  """Six element tuple for the transform in shapely.transform format. See
122  https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations and
123  methods in `weavingspace.tiling_utils`."""
124  offset: int
125
126  def __init__(self, transform_type:str, angle:float, centre:geom.Point, 
127               translation: tuple[float], transform:tuple[float]):
128    self.transform_type = transform_type
129    self.angle = angle
130    self.centre = centre
131    self.translation = translation
132    self.transform = transform
133
134  def __str__(self) -> str:
135    if self.transform_type == "rotation":
136      return f"{self.transform_type} {self.angle:.1f}° POINT ({self.centre.x:.1f} {self.centre.y:.1f}) {tuple([np.round(x, 3) for x in self.transform])}"
137    elif self.transform_type == "reflection":
138      return f"{self.transform_type} {self.angle:.1f}° {tuple([np.round(x, 3) for x in self.transform])}"
139    else:
140      return f"{self.transform_type} {tuple([np.round(x, 3) for x in self.transform])}"
141
142  def __repr__(self) -> str:
143    return str(self)
144
145  def apply(self, geometry:Any) -> Any:
146    """Applies this transform to supplied shapely geometry and returns result.
147
148    Args:
149      geometry (Any): a shapely geometry to transform.
150    
151    Returns:
152      Any: the resulting transformed shapely geometry.
153    """
154    return affine.affine_transform(geometry, self.transform)
155
156  def draw(self, ax:pyplot.axes, **kwargs) -> pyplot.Axes:
157    """Draws this transform on the supplied axes. Arguments specific to each
158    transform type are supplied as **kwargs and are documented in 
159    `draw_rotation`, `draw_reflection`, and `draw_translation`.
160
161    Args:
162        ax (pyplot.axes): the axes on which to draw the transform.
163
164    Returns:
165        pyplot.Axes: the axes with the rendering of this transform added.
166    """
167    if self.transform_type == "rotation":
168      rotn_args = list(inspect.signature(self.draw_rotation).parameters)
169      rotn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in rotn_args}
170      return self.draw_rotation(ax = ax, **rotn_dict)
171    elif self.transform_type == "reflection":
172      refn_args = list(inspect.signature(self.draw_reflection).parameters)
173      refn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in refn_args}
174      return self.draw_reflection(ax = ax, **refn_dict)
175    if self.transform_type == "translation":
176      trans_args = list(inspect.signature(self.draw_translation).parameters)
177      trans_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in trans_args}
178      return self.draw_translation(ax = ax, **trans_dict)
179    elif self.transform_type == "identity":
180      w = ax.get_window_extent().width
181      return ax.set_title(f"{self.transform_type}", fontsize = w / 20)
182
183  def draw_rotation(self, ax:pyplot.Axes, 
184                    radius = 200, add_title = True) -> pyplot.Axes:
185    x, y = self.centre.x, self.centre.y
186    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
187    arc = geom.LineString([
188      geom.Point(x + radius * np.cos(np.radians(a)),
189                  y + radius * np.sin(np.radians(a)))
190      for a in np.linspace(0, self.angle, 50)])
191    gpd.GeoSeries([self.centre]).plot(
192      ax = ax, color = "r", markersize = 4, zorder = 5)
193    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "r", lw = .5)
194    if add_title:
195      w = ax.get_window_extent().width
196      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
197                   fontsize = w / 20)
198    return ax
199
200  def draw_reflection(self, ax:pyplot.Axes, w = 5, 
201                      mirror_length = 100, add_title = True) -> pyplot.Axes:
202    x, y = self.centre.x, self.centre.y
203    dx, dy = self.translation
204    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2) 
205    mirror = geom.LineString([
206      (x - mirror_length / 2 * np.cos(np.radians(self.angle)), 
207        y - mirror_length / 2 * np.sin(np.radians(self.angle))),
208      (x + mirror_length / 2 * np.cos(np.radians(self.angle)), 
209        y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
210    gpd.GeoSeries([mirror]).plot(
211      ax = ax, color = "r", lw = 1, ls = "dashdot", zorder = 5)
212    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
213    if not no_slide:
214      pyplot.arrow(
215        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
216        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
217    if add_title:
218      w = ax.get_window_extent().width
219      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
220                   fontsize = w / 20)
221    return ax
222
223  def draw_translation(self, ax:pyplot.Axes, c:geom.Point, 
224                       w:float = 5, add_title = True) -> pyplot.Axes:
225    gpd.GeoSeries([c]).plot(ax = ax, color = "b")
226    pyplot.arrow(c.x, c.y, self.translation[0], self.translation[1], lw = 0.5,
227                width = w, fc = "b", ec = None, head_width = w * 6, zorder = 5)
228    if add_title:
229      w = ax.get_window_extent().width
230      rounded_tr = tuple([np.round(x, 1) for x in self.translation])
231      ax.set_title(f"{self.transform_type} {rounded_tr}",
232                   fontsize = w / 20)
233    return ax

Class to store details of a transform and draw it.

Transform( transform_type: str, angle: float, centre: shapely.geometry.point.Point, translation: tuple[float], transform: tuple[float])
126  def __init__(self, transform_type:str, angle:float, centre:geom.Point, 
127               translation: tuple[float], transform:tuple[float]):
128    self.transform_type = transform_type
129    self.angle = angle
130    self.centre = centre
131    self.translation = translation
132    self.transform = transform
transform_type: str

Type of transform, 'rotation', 'reflection', 'translation' or 'identity'.

angle: float

Angle of rotation (degrees).

centre: shapely.geometry.point.Point

Centre of the transformation.

translation: tuple[float]

X and Y coordinates shifts of a translation transform. A glide reflection may also include this.

transform: tuple[float]

Six element tuple for the transform in shapely.transform format. See https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations and methods in weavingspace.tiling_utils.

offset: int
def apply(self, geometry: Any) -> Any:
145  def apply(self, geometry:Any) -> Any:
146    """Applies this transform to supplied shapely geometry and returns result.
147
148    Args:
149      geometry (Any): a shapely geometry to transform.
150    
151    Returns:
152      Any: the resulting transformed shapely geometry.
153    """
154    return affine.affine_transform(geometry, self.transform)

Applies this transform to supplied shapely geometry and returns result.

Args: geometry (Any): a shapely geometry to transform.

Returns: Any: the resulting transformed shapely geometry.

def draw(self, ax: <function axes>, **kwargs) -> matplotlib.axes._axes.Axes:
156  def draw(self, ax:pyplot.axes, **kwargs) -> pyplot.Axes:
157    """Draws this transform on the supplied axes. Arguments specific to each
158    transform type are supplied as **kwargs and are documented in 
159    `draw_rotation`, `draw_reflection`, and `draw_translation`.
160
161    Args:
162        ax (pyplot.axes): the axes on which to draw the transform.
163
164    Returns:
165        pyplot.Axes: the axes with the rendering of this transform added.
166    """
167    if self.transform_type == "rotation":
168      rotn_args = list(inspect.signature(self.draw_rotation).parameters)
169      rotn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in rotn_args}
170      return self.draw_rotation(ax = ax, **rotn_dict)
171    elif self.transform_type == "reflection":
172      refn_args = list(inspect.signature(self.draw_reflection).parameters)
173      refn_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in refn_args}
174      return self.draw_reflection(ax = ax, **refn_dict)
175    if self.transform_type == "translation":
176      trans_args = list(inspect.signature(self.draw_translation).parameters)
177      trans_dict = {k: kwargs.pop(k) for k in dict(kwargs) if k in trans_args}
178      return self.draw_translation(ax = ax, **trans_dict)
179    elif self.transform_type == "identity":
180      w = ax.get_window_extent().width
181      return ax.set_title(f"{self.transform_type}", fontsize = w / 20)

Draws this transform on the supplied axes. Arguments specific to each transform type are supplied as **kwargs and are documented in draw_rotation, draw_reflection, and draw_translation.

Args: ax (pyplot.axes): the axes on which to draw the transform.

Returns: pyplot.Axes: the axes with the rendering of this transform added.

def draw_rotation( self, ax: matplotlib.axes._axes.Axes, radius=200, add_title=True) -> matplotlib.axes._axes.Axes:
183  def draw_rotation(self, ax:pyplot.Axes, 
184                    radius = 200, add_title = True) -> pyplot.Axes:
185    x, y = self.centre.x, self.centre.y
186    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
187    arc = geom.LineString([
188      geom.Point(x + radius * np.cos(np.radians(a)),
189                  y + radius * np.sin(np.radians(a)))
190      for a in np.linspace(0, self.angle, 50)])
191    gpd.GeoSeries([self.centre]).plot(
192      ax = ax, color = "r", markersize = 4, zorder = 5)
193    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "r", lw = .5)
194    if add_title:
195      w = ax.get_window_extent().width
196      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
197                   fontsize = w / 20)
198    return ax
def draw_reflection( self, ax: matplotlib.axes._axes.Axes, w=5, mirror_length=100, add_title=True) -> matplotlib.axes._axes.Axes:
200  def draw_reflection(self, ax:pyplot.Axes, w = 5, 
201                      mirror_length = 100, add_title = True) -> pyplot.Axes:
202    x, y = self.centre.x, self.centre.y
203    dx, dy = self.translation
204    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2) 
205    mirror = geom.LineString([
206      (x - mirror_length / 2 * np.cos(np.radians(self.angle)), 
207        y - mirror_length / 2 * np.sin(np.radians(self.angle))),
208      (x + mirror_length / 2 * np.cos(np.radians(self.angle)), 
209        y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
210    gpd.GeoSeries([mirror]).plot(
211      ax = ax, color = "r", lw = 1, ls = "dashdot", zorder = 5)
212    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
213    if not no_slide:
214      pyplot.arrow(
215        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
216        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
217    if add_title:
218      w = ax.get_window_extent().width
219      ax.set_title(f"{self.transform_type} {np.round(self.angle, 2)}",
220                   fontsize = w / 20)
221    return ax
def draw_translation( self, ax: matplotlib.axes._axes.Axes, c: shapely.geometry.point.Point, w: float = 5, add_title=True) -> matplotlib.axes._axes.Axes:
223  def draw_translation(self, ax:pyplot.Axes, c:geom.Point, 
224                       w:float = 5, add_title = True) -> pyplot.Axes:
225    gpd.GeoSeries([c]).plot(ax = ax, color = "b")
226    pyplot.arrow(c.x, c.y, self.translation[0], self.translation[1], lw = 0.5,
227                width = w, fc = "b", ec = None, head_width = w * 6, zorder = 5)
228    if add_title:
229      w = ax.get_window_extent().width
230      rounded_tr = tuple([np.round(x, 1) for x in self.translation])
231      ax.set_title(f"{self.transform_type} {rounded_tr}",
232                   fontsize = w / 20)
233    return ax
@dataclass
class Symmetries:
236@dataclass
237class Symmetries():
238  """Class to identify and store the symmetries of a supplied shapely.Polygon
239  as a list of `Transform` objects.
240  """
241  polygon:geom.Polygon = None
242  """Polygon from which these symmetries are derived."""
243  matcher:KMP_Matcher = None
244  """the subsequence matcher used in symmetry detection."""
245  n:int = None
246  """number of vertices of the polygon."""
247  p_code:list[tuple[float]] = None
248  """the encoding of the polygon as sequence of length, angle pairs."""
249  p_code_r:list[tuple[float]] = None
250  """the reversed encoding used to detect reflection symmetries."""
251  rotation_shifts:list[int] = None
252  """list of number of 2pi/n rotation symmetries."""
253  reflection_shifts:list[int] = None
254  """list of pi/n relection angle symmetries."""
255  symmetries:list[Transform] = None
256  """list of Transform objects with more complete information."""
257  symmetry_group:str = None
258  """the code denoting the symmetry group"""
259
260  def __init__(self, polygon:geom.Polygon):
261    self.polygon = polygon
262    self.n = shapely.count_coordinates(self.polygon) - 1
263    self.p_code = self._get_polygon_code(self.polygon)
264    self.p_code_r = self._get_polygon_code(self.polygon, mirrored = True)
265    self.matcher = KMP_Matcher(self.p_code + self.p_code[:-1])
266    self.symmetries = self.get_symmetries()
267    self.symmetry_group = self.get_symmetry_group_code()
268
269  def _get_polygon_code(self, p:geom.Polygon, 
270                        mirrored = False) -> list[tuple[float]]:
271    r"""Returns a list of length-angle pairs to uniquely encode a polygon
272    shape (up to scale). The alignment of lengths and angles is important
273    and symmetry detection is sensitively dependent on it...
274
275    The forward (i.e. unmirrored) polygon pairs are of edge lengths and the 
276    angle between each edge and its successor going CW around the polygon. 
277    
278           A[i] ----L[i]---- A (i+1)
279          /                   \
280         /                     \
281                             
282    i.e. edge i is between angles i and i + 1, and angle i is between edge
283    i-1 and i. The unmirrored encoding pairs length[i] with angle[i+1].
284    
285    The mirrored encoding pairs matched indexes of lengths and angles in the
286    original polygon. This means the angle is still the one between and edge 
287    and its successor but proceeding CCW around the polygon.
288    
289    See in particular for clarification.
290    
291    Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and 
292    Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. 
293    North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
294    
295    Args:
296      p (geom.Polygon): the polygon to encode
297      mirrored (bool, optional): if true encoding will be in CCC order. 
298        Defaults to False.
299
300    Returns:
301      list[tuple[float]]: _description_
302    """
303    # get edge lengths and angles
304    lengths = tiling_utils.get_side_lengths(p)
305    raw_angles = tiling_utils.get_interior_angles(p)
306    if mirrored:
307      lengths_r = list(reversed(lengths))
308      angles_r = list(reversed(raw_angles))
309      return list(zip(lengths_r, angles_r))
310    else:
311      angles = raw_angles[1:] + raw_angles[:1]
312      return list(zip(lengths, angles))
313
314  def get_symmetries(self) -> list[Transform]:
315    """Finds rotation and reflection symmetries of the supplied polygon.
316    Based on
317    
318      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and 
319      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. 
320      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
321    
322    and also
323    
324      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry 
325      detection in two and three dimensions. The Visual Computer 1(1): 37-48. 
326      doi: 10.1007/BF01901268.
327    
328    Details in these papers are not unambiguous. This implementation was
329    developed based on them, but with a lot of trial and error to get the index
330    offsets and (especially) retrieval of the reflection axes angles to work.
331
332    Returns:
333      list[Transform]: a list of Transform objects representing the polygon
334        symmetries.
335    """
336    # ==== Rotations ====
337    self.rotation_shifts = self.matcher.find_matches(self.p_code)
338    # ==== Reflections ====
339    self.reflection_shifts = self.matcher.find_matches(self.p_code_r)
340    return self.get_rotations(self.rotation_shifts) + \
341           self.get_reflections(self.reflection_shifts)
342
343  def get_rotations(self, offsets:list[int]) -> list[Transform]:
344    """Gets the rotations associated with this collection of symmetries.
345
346    Returns:
347      list[Transform]: A list of the rotation symmetries associated with this
348        polygon.
349    """
350    c = self.polygon.centroid
351    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets] 
352    rotation_transforms = [
353      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6) 
354      for a in rot_angles]
355    return [Transform("rotation", angle, c, (0, 0), transform)
356            for transform, angle in zip(rotation_transforms, rot_angles)]
357
358  def get_reflections(self, offsets:list[int]) -> list[Transform]:
359    """Gets the reflections associated with this collection of symmetries.
360
361    Returns:
362      list[Transform]: A list of the reflection symmetries associated with this
363        polygon.
364    """
365    # calculate all possible reflection axes - ordering of these is important
366    # for correct picking - don't mess with this code!
367    c = self.polygon.centroid
368    bearings = tiling_utils.get_side_bearings(self.polygon)
369    reflection_axes = []
370    interior_angles = tiling_utils.get_interior_angles(self.polygon)
371    for i in range(self.n):
372      # angle bisectors
373      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
374      # edge_perpendicular bisectors
375      reflection_axes.append(bearings[i] - 90)  # normal to the edge
376    # pick out those where matches occurred
377    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
378    reflection_transforms = [
379      np.round(tiling_utils.get_reflection_transform(a, (c.x, c.y)), 6)
380      for a in ref_angles]
381    return [Transform("reflection", angle, c, (0, 0), transform)
382            for transform, angle in zip(reflection_transforms, ref_angles)]
383
384  def get_symmetry_group_code(self):
385    if len(self.reflection_shifts) == 0:
386      return f"C{len(self.rotation_shifts)}"
387    else:
388      return f"D{len(self.rotation_shifts)}"
389
390  def get_corner_offset(self, poly2:geom.Polygon):
391    s2 = Symmetries(poly2)
392    matches = self.matcher.find_matches(s2.p_code)
393    if len(matches) > 0:
394      return matches[0]
395    matches = self.matcher.find_matches(s2.p_code_r)
396    if len(matches) > 0:
397      return -matches[0]
398    return None
399
400  def get_corner_labels(self) -> dict[str, list[str]]:
401    """Returns all the reorderings of vertex labels corresponding to each
402    symmetry.
403
404    Returns:
405      dict[str, list[str]]: A dictionary with two entries. "rotations" is
406        a list of labels under the rotation symmetries, "reflections" those
407        under the reflection symmetries.
408    """
409    labels = list(string.ascii_letters.upper())[:self.n]
410    return self._get_labels_under_symmetries(labels)
411
412  def get_unique_labels(self, offset:int = 0) -> list[str]:
413    labellings = self.get_corner_labels()
414    labellings = labellings["rotations"] + labellings["reflections"]
415    labellings = ["".join(sorted(x)) for x in zip(*labellings)]
416    n_new_labels = len(set(labellings))
417    letters = list(string.ascii_letters.upper())[offset:offset + n_new_labels]
418    mapping = dict()
419    i = 0
420    for label in labellings:
421      if not label in mapping:
422        mapping[label] = letters[i]
423        i = i + 1
424    return self._get_labels_under_symmetries([mapping[x] for x in labellings])
425  
426  def _get_labels_under_symmetries(
427      self, labels:list[str]) -> dict[str, list[str]]:
428    cycle = labels * 2
429    under_rotation = ["".join(cycle[i:self.n + i]) 
430                      for i in self.rotation_shifts]
431    under_reflection = ["".join(cycle[self.n + i:i:-1]) 
432                        for i in self.reflection_shifts]
433    return {"rotations": under_rotation,
434            "reflections": under_reflection}
435
436  def plot(self, as_image:bool = False, title:str = ""):
437    fig = pyplot.figure()
438    fig.suptitle(title)
439
440    n_subplots = len(self.symmetries)
441    nr = int(np.ceil(np.sqrt(n_subplots)))
442    nc = int(np.ceil(n_subplots / nr))
443    n_plots = 0
444
445    for s in self.symmetries:
446      n_plots = n_plots + 1
447      ax = fig.add_subplot(nr, nc, n_plots)
448      s.plot(ax, self.polygon)
449    
450    if as_image:
451      buf = io.BytesIO()
452      fig.savefig(buf)
453      buf.seek(0)
454      return PIL.Image.open(buf)
455    return ax

Class to identify and store the symmetries of a supplied shapely.Polygon as a list of Transform objects.

Symmetries(polygon: shapely.geometry.polygon.Polygon)
260  def __init__(self, polygon:geom.Polygon):
261    self.polygon = polygon
262    self.n = shapely.count_coordinates(self.polygon) - 1
263    self.p_code = self._get_polygon_code(self.polygon)
264    self.p_code_r = self._get_polygon_code(self.polygon, mirrored = True)
265    self.matcher = KMP_Matcher(self.p_code + self.p_code[:-1])
266    self.symmetries = self.get_symmetries()
267    self.symmetry_group = self.get_symmetry_group_code()
polygon: shapely.geometry.polygon.Polygon = None

Polygon from which these symmetries are derived.

matcher: KMP_Matcher = None

the subsequence matcher used in symmetry detection.

n: int = None

number of vertices of the polygon.

p_code: list[tuple[float]] = None

the encoding of the polygon as sequence of length, angle pairs.

p_code_r: list[tuple[float]] = None

the reversed encoding used to detect reflection symmetries.

rotation_shifts: list[int] = None

list of number of 2pi/n rotation symmetries.

reflection_shifts: list[int] = None

list of pi/n relection angle symmetries.

symmetries: list[Transform] = None

list of Transform objects with more complete information.

symmetry_group: str = None

the code denoting the symmetry group

def get_symmetries(self) -> list[Transform]:
314  def get_symmetries(self) -> list[Transform]:
315    """Finds rotation and reflection symmetries of the supplied polygon.
316    Based on
317    
318      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and 
319      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. 
320      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
321    
322    and also
323    
324      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry 
325      detection in two and three dimensions. The Visual Computer 1(1): 37-48. 
326      doi: 10.1007/BF01901268.
327    
328    Details in these papers are not unambiguous. This implementation was
329    developed based on them, but with a lot of trial and error to get the index
330    offsets and (especially) retrieval of the reflection axes angles to work.
331
332    Returns:
333      list[Transform]: a list of Transform objects representing the polygon
334        symmetries.
335    """
336    # ==== Rotations ====
337    self.rotation_shifts = self.matcher.find_matches(self.p_code)
338    # ==== Reflections ====
339    self.reflection_shifts = self.matcher.find_matches(self.p_code_r)
340    return self.get_rotations(self.rotation_shifts) + \
341           self.get_reflections(self.reflection_shifts)

Finds rotation and reflection symmetries of the supplied polygon. Based on

Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.

and also

Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry detection in two and three dimensions. The Visual Computer 1(1): 37-48. doi: 10.1007/BF01901268.

Details in these papers are not unambiguous. This implementation was developed based on them, but with a lot of trial and error to get the index offsets and (especially) retrieval of the reflection axes angles to work.

Returns: list[Transform]: a list of Transform objects representing the polygon symmetries.

def get_rotations(self, offsets: list[int]) -> list[Transform]:
343  def get_rotations(self, offsets:list[int]) -> list[Transform]:
344    """Gets the rotations associated with this collection of symmetries.
345
346    Returns:
347      list[Transform]: A list of the rotation symmetries associated with this
348        polygon.
349    """
350    c = self.polygon.centroid
351    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets] 
352    rotation_transforms = [
353      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6) 
354      for a in rot_angles]
355    return [Transform("rotation", angle, c, (0, 0), transform)
356            for transform, angle in zip(rotation_transforms, rot_angles)]

Gets the rotations associated with this collection of symmetries.

Returns: list[Transform]: A list of the rotation symmetries associated with this polygon.

def get_reflections(self, offsets: list[int]) -> list[Transform]:
358  def get_reflections(self, offsets:list[int]) -> list[Transform]:
359    """Gets the reflections associated with this collection of symmetries.
360
361    Returns:
362      list[Transform]: A list of the reflection symmetries associated with this
363        polygon.
364    """
365    # calculate all possible reflection axes - ordering of these is important
366    # for correct picking - don't mess with this code!
367    c = self.polygon.centroid
368    bearings = tiling_utils.get_side_bearings(self.polygon)
369    reflection_axes = []
370    interior_angles = tiling_utils.get_interior_angles(self.polygon)
371    for i in range(self.n):
372      # angle bisectors
373      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
374      # edge_perpendicular bisectors
375      reflection_axes.append(bearings[i] - 90)  # normal to the edge
376    # pick out those where matches occurred
377    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
378    reflection_transforms = [
379      np.round(tiling_utils.get_reflection_transform(a, (c.x, c.y)), 6)
380      for a in ref_angles]
381    return [Transform("reflection", angle, c, (0, 0), transform)
382            for transform, angle in zip(reflection_transforms, ref_angles)]

Gets the reflections associated with this collection of symmetries.

Returns: list[Transform]: A list of the reflection symmetries associated with this polygon.

def get_symmetry_group_code(self):
384  def get_symmetry_group_code(self):
385    if len(self.reflection_shifts) == 0:
386      return f"C{len(self.rotation_shifts)}"
387    else:
388      return f"D{len(self.rotation_shifts)}"
def get_corner_offset(self, poly2: shapely.geometry.polygon.Polygon):
390  def get_corner_offset(self, poly2:geom.Polygon):
391    s2 = Symmetries(poly2)
392    matches = self.matcher.find_matches(s2.p_code)
393    if len(matches) > 0:
394      return matches[0]
395    matches = self.matcher.find_matches(s2.p_code_r)
396    if len(matches) > 0:
397      return -matches[0]
398    return None
def get_corner_labels(self) -> dict[str, list[str]]:
400  def get_corner_labels(self) -> dict[str, list[str]]:
401    """Returns all the reorderings of vertex labels corresponding to each
402    symmetry.
403
404    Returns:
405      dict[str, list[str]]: A dictionary with two entries. "rotations" is
406        a list of labels under the rotation symmetries, "reflections" those
407        under the reflection symmetries.
408    """
409    labels = list(string.ascii_letters.upper())[:self.n]
410    return self._get_labels_under_symmetries(labels)

Returns all the reorderings of vertex labels corresponding to each symmetry.

Returns: dict[str, list[str]]: A dictionary with two entries. "rotations" is a list of labels under the rotation symmetries, "reflections" those under the reflection symmetries.

def get_unique_labels(self, offset: int = 0) -> list[str]:
412  def get_unique_labels(self, offset:int = 0) -> list[str]:
413    labellings = self.get_corner_labels()
414    labellings = labellings["rotations"] + labellings["reflections"]
415    labellings = ["".join(sorted(x)) for x in zip(*labellings)]
416    n_new_labels = len(set(labellings))
417    letters = list(string.ascii_letters.upper())[offset:offset + n_new_labels]
418    mapping = dict()
419    i = 0
420    for label in labellings:
421      if not label in mapping:
422        mapping[label] = letters[i]
423        i = i + 1
424    return self._get_labels_under_symmetries([mapping[x] for x in labellings])
def plot(self, as_image: bool = False, title: str = ''):
436  def plot(self, as_image:bool = False, title:str = ""):
437    fig = pyplot.figure()
438    fig.suptitle(title)
439
440    n_subplots = len(self.symmetries)
441    nr = int(np.ceil(np.sqrt(n_subplots)))
442    nc = int(np.ceil(n_subplots / nr))
443    n_plots = 0
444
445    for s in self.symmetries:
446      n_plots = n_plots + 1
447      ax = fig.add_subplot(nr, nc, n_plots)
448      s.plot(ax, self.polygon)
449    
450    if as_image:
451      buf = io.BytesIO()
452      fig.savefig(buf)
453      buf.seek(0)
454      return PIL.Image.open(buf)
455    return ax
class StraightLine(builtins.tuple):

Named tuple to represent equation of a straight line in standard Ax + By + C = 0 form.

StraightLine(A, B, C)

Create new instance of StraightLine(A, B, C)

A

Alias for field number 0

B

Alias for field number 1

C

Alias for field number 2

Inherited Members
builtins.tuple
index
count
class Shape_Matcher:
463class Shape_Matcher:
464  shape: geom.Polygon
465  s1: Symmetries
466  other: geom.Polygon
467  centre: geom.Point
468  translation: tuple[float]
469  matches: list[Transform]
470  identity_transform: tuple[float] = (1, 0, 0, 1, 0, 0)
471  
472  def __init__(self, shape: geom.Polygon):
473    self.shape = shape
474    self.s1 = Symmetries(shape)
475
476  def get_polygon_matches(self, shape2: geom.Polygon):
477    s2 = Symmetries(shape2)
478    if self.s1.symmetry_group != s2.symmetry_group:
479      # print("No matches")
480      return None
481    else:
482      match_rot, rots = self._get_rotation_matches(s2)
483      match_ref, refs = self._get_reflection_matches(s2)
484      if match_rot and match_ref:
485        # print(f"Rotation and reflection matches found")
486        return rots + refs
487      elif match_rot:
488        # print(f"Only rotation matches found")
489        return rots
490      elif match_ref:
491        # print(f"Only reflection matches found")
492        return refs
493      else:
494        # print (f"No matches found")
495        return None
496
497  def _get_rotation_matches(self, s2:Symmetries):
498    matches = self.s1.matcher.find_matches(s2.p_code)
499    if len(matches) == 0:
500      return False, None
501    else:
502      transforms = []
503      # get lists of polygon corners aligned correctly to measure the angle
504      p1_corners = tiling_utils.get_corners(self.shape, repeat_first = False)
505      p2_corners = tiling_utils.get_corners(s2.polygon, repeat_first = False)
506      for m in matches:
507        a, c = self.get_angle_between_polygons(p1_corners, p2_corners, m)
508        if a == "translation":
509          # print("One of the rotation matches is a translation.")
510          transforms.append(
511            Transform("translation", None, None, c, (1, 0, 0, 1, c[0], c[1])))
512        elif a == "identity":
513          transforms.append(
514            Transform("identity", None, None, c, (1, 0, 0, 1, 0, 0)))
515        elif a is None:
516          continue
517        else:
518          transforms.append(Transform("rotation", a, c, (0, 0),
519            tiling_utils.get_rotation_transform(a, (c.x, c.y))))
520      return True, transforms
521
522  def get_angle_between_polygons(self, corners1:list[geom.Point], 
523      corners2:list[geom.Point], offset:int) -> tuple[Union[float,geom.Point]]:
524    corners2 = corners2[offset:] + corners2[:offset]
525    dists = [p1.distance(p2) for p1, p2 in zip(corners1, corners2)]
526    if all([np.isclose(dists[0], d, atol = 1e-3, rtol = 1e-3) for d in dists]):
527      if np.isclose(dists[0], 0, atol = 1e-3, rtol  = 1e-3):
528        return "identity", (0, 0)
529      else:
530        return "translation", (corners1[0].x - corners2[0].x,
531                               corners1[0].y - corners2[0].y)
532    ordered_dists = sorted([(i, d) for i, d in enumerate(dists)], 
533                           key = lambda x: x[1])
534    AB = ordered_dists[-2:]
535    p1A, p1B = corners1[AB[0][0]], corners1[AB[1][0]]
536    p2A, p2B = corners2[AB[0][0]], corners2[AB[1][0]]
537    perpAA = self.get_straight_line(p1A, p2A, True)
538    perpBB = self.get_straight_line(p1B, p2B, True)
539    centre = self.get_intersection(perpAA, perpBB)
540    if centre is None:
541      angle = None
542    else:
543      angle = -tiling_utils.get_inner_angle(p1A, centre, p2A)
544      if angle < -179:
545        angle = angle + 360
546      elif angle > 180:
547        angle = angle - 360
548    return angle, centre
549
550  def get_straight_line(self, p1:geom.Point, p2:geom.Point, 
551                        perpendicular:bool = False) -> StraightLine:
552    if perpendicular:
553      ls = affine.rotate(geom.LineString([p1, p2]), 90)
554      pts = [p for p in ls.coords]
555      x1, y1 = pts[0]
556      x2, y2 = pts[1]
557    else:
558      x1, y1 = p1.x, p1.y
559      x2, y2 = p2.x, p2.y
560    return StraightLine(y1 - y2, x2 - x1, x1 * y2 - x2 * y1)
561
562  def get_intersection(self, line1:StraightLine, 
563                       line2:StraightLine) -> geom.Point:
564    x_set, y_set = False, False
565    denominator = line1.A * line2.B - line2.A * line1.B
566    if np.isclose(line1.A, 0, atol = 1e-4, rtol = 1e-4):
567      y = -line1.C / line1.B
568      y_set = True
569    elif np.isclose(line2.A, 0, atol = 1e-4, rtol = 1e-4):
570      y = -line2.C / line2.B
571      y_set = True
572    if np.isclose(line1.B, 0, atol = 1e-4, rtol = 1e-4):
573      x = -line1.C / line1.A
574      x_set = True
575    elif np.isclose(line2.B, 0, atol = 1e-4, rtol = 1e-4):
576      x = -line2.C / line2.A
577      x_set = True
578    if np.isclose(denominator, 0, atol = 1e-4, rtol = 1e-4):
579      return None
580    x = x if x_set else (line1.B * line2.C - line2.B * line1.C) / denominator
581    y = y if y_set else (line1.C * line2.A - line2.C * line1.A) / denominator
582    return geom.Point(x, y)
583
584  def _get_reflection_matches(self, s2:Symmetries):
585    ctr1 = self.s1.polygon.centroid
586    ctr2 = s2.polygon.centroid
587    c = ((ctr1.x + ctr2.x) / 2, (ctr1.y + ctr2.y) / 2)
588    matches = self.s1.matcher.find_matches(s2.p_code_r)
589    if len(matches) == 0:
590      return False, None
591    reflections1 = self.s1.get_reflections(matches)
592    reflections2 = s2.get_reflections(matches)
593    angles = [(ref1.angle + ref2.angle) / 2
594              for ref1, ref2 in zip(reflections1, reflections2)]
595    trs = [tiling_utils.get_reflection_transform(angle, c)
596           for angle in angles]
597    ctrs2r = [affine.affine_transform(ctr2, tr) for tr in trs]
598    dxdys = [(ctr1.x - ctr2r.x, ctr1.y - ctr2r.y) for ctr2r in ctrs2r]
599    return True, [Transform(
600      "reflection", angle, geom.Point(c), dxdy,
601      tiling_utils.combine_transforms(
602        [tiling_utils.get_reflection_transform(angle, c), 
603         (1, 0, 0, 1, dxdy[0], dxdy[1])])) 
604      for angle, dxdy in zip(angles, dxdys)]
Shape_Matcher(shape: shapely.geometry.polygon.Polygon)
472  def __init__(self, shape: geom.Polygon):
473    self.shape = shape
474    self.s1 = Symmetries(shape)
shape: shapely.geometry.polygon.Polygon
other: shapely.geometry.polygon.Polygon
centre: shapely.geometry.point.Point
translation: tuple[float]
matches: list[Transform]
identity_transform: tuple[float] = (1, 0, 0, 1, 0, 0)
def get_polygon_matches(self, shape2: shapely.geometry.polygon.Polygon):
476  def get_polygon_matches(self, shape2: geom.Polygon):
477    s2 = Symmetries(shape2)
478    if self.s1.symmetry_group != s2.symmetry_group:
479      # print("No matches")
480      return None
481    else:
482      match_rot, rots = self._get_rotation_matches(s2)
483      match_ref, refs = self._get_reflection_matches(s2)
484      if match_rot and match_ref:
485        # print(f"Rotation and reflection matches found")
486        return rots + refs
487      elif match_rot:
488        # print(f"Only rotation matches found")
489        return rots
490      elif match_ref:
491        # print(f"Only reflection matches found")
492        return refs
493      else:
494        # print (f"No matches found")
495        return None
def get_angle_between_polygons( self, corners1: list[shapely.geometry.point.Point], corners2: list[shapely.geometry.point.Point], offset: int) -> tuple[typing.Union[float, shapely.geometry.point.Point]]:
522  def get_angle_between_polygons(self, corners1:list[geom.Point], 
523      corners2:list[geom.Point], offset:int) -> tuple[Union[float,geom.Point]]:
524    corners2 = corners2[offset:] + corners2[:offset]
525    dists = [p1.distance(p2) for p1, p2 in zip(corners1, corners2)]
526    if all([np.isclose(dists[0], d, atol = 1e-3, rtol = 1e-3) for d in dists]):
527      if np.isclose(dists[0], 0, atol = 1e-3, rtol  = 1e-3):
528        return "identity", (0, 0)
529      else:
530        return "translation", (corners1[0].x - corners2[0].x,
531                               corners1[0].y - corners2[0].y)
532    ordered_dists = sorted([(i, d) for i, d in enumerate(dists)], 
533                           key = lambda x: x[1])
534    AB = ordered_dists[-2:]
535    p1A, p1B = corners1[AB[0][0]], corners1[AB[1][0]]
536    p2A, p2B = corners2[AB[0][0]], corners2[AB[1][0]]
537    perpAA = self.get_straight_line(p1A, p2A, True)
538    perpBB = self.get_straight_line(p1B, p2B, True)
539    centre = self.get_intersection(perpAA, perpBB)
540    if centre is None:
541      angle = None
542    else:
543      angle = -tiling_utils.get_inner_angle(p1A, centre, p2A)
544      if angle < -179:
545        angle = angle + 360
546      elif angle > 180:
547        angle = angle - 360
548    return angle, centre
def get_straight_line( self, p1: shapely.geometry.point.Point, p2: shapely.geometry.point.Point, perpendicular: bool = False) -> StraightLine:
550  def get_straight_line(self, p1:geom.Point, p2:geom.Point, 
551                        perpendicular:bool = False) -> StraightLine:
552    if perpendicular:
553      ls = affine.rotate(geom.LineString([p1, p2]), 90)
554      pts = [p for p in ls.coords]
555      x1, y1 = pts[0]
556      x2, y2 = pts[1]
557    else:
558      x1, y1 = p1.x, p1.y
559      x2, y2 = p2.x, p2.y
560    return StraightLine(y1 - y2, x2 - x1, x1 * y2 - x2 * y1)
def get_intersection( self, line1: StraightLine, line2: StraightLine) -> shapely.geometry.point.Point:
562  def get_intersection(self, line1:StraightLine, 
563                       line2:StraightLine) -> geom.Point:
564    x_set, y_set = False, False
565    denominator = line1.A * line2.B - line2.A * line1.B
566    if np.isclose(line1.A, 0, atol = 1e-4, rtol = 1e-4):
567      y = -line1.C / line1.B
568      y_set = True
569    elif np.isclose(line2.A, 0, atol = 1e-4, rtol = 1e-4):
570      y = -line2.C / line2.B
571      y_set = True
572    if np.isclose(line1.B, 0, atol = 1e-4, rtol = 1e-4):
573      x = -line1.C / line1.A
574      x_set = True
575    elif np.isclose(line2.B, 0, atol = 1e-4, rtol = 1e-4):
576      x = -line2.C / line2.A
577      x_set = True
578    if np.isclose(denominator, 0, atol = 1e-4, rtol = 1e-4):
579      return None
580    x = x if x_set else (line1.B * line2.C - line2.B * line1.C) / denominator
581    y = y if y_set else (line1.C * line2.A - line2.C * line1.A) / denominator
582    return geom.Point(x, y)