From 92ace79b991d09f7e860a38a2b5508bbe4a0ace9 Mon Sep 17 00:00:00 2001 From: Windell Oskay Date: Sun, 2 Jan 2022 22:53:14 -0800 Subject: [PATCH 1/4] Alternate approach for spatial index Improve speed of plot optimization. Use a rectangular grid to map coordinate locations. For each vertex, use its grid cell and its neighboring grid cells as the region to search first for the nearest vertex. --- inkscape driver/plot_optimizations.py | 108 +++++------ inkscape driver/spatial_grid.py | 246 ++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 62 deletions(-) create mode 100644 inkscape driver/spatial_grid.py diff --git a/inkscape driver/plot_optimizations.py b/inkscape driver/plot_optimizations.py index 6478e25..f962abc 100644 --- a/inkscape driver/plot_optimizations.py +++ b/inkscape driver/plot_optimizations.py @@ -1,6 +1,6 @@ # coding=utf-8 # -# Copyright 2021 Windell H. Oskay, Evil Mad Scientist Laboratories +# Copyright 2022 Windell H. Oskay, Evil Mad Scientist Laboratories # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,7 +19,7 @@ """ plot_optimizations.py -Version 1.1.0 - 2021-12-21 +Version 1.1.0 - 2022-01-03 This module provides some plot optimization tools. @@ -48,15 +48,18 @@ - If path reversal is enabled, allow paths to be reversed when sorting """ - import random import copy +import math +from axidrawinternal.plot_utils_import import from_dependency_import # plotink from . import rtree -from axidrawinternal.plot_utils_import import from_dependency_import # plotink +from . import spatial_grid + path_objects = from_dependency_import('axidrawinternal.path_objects') plot_utils = from_dependency_import('plotink.plot_utils') + def connect_nearby_ends(digest, reverse, min_gap): """ Step through all PathItem objects in each layer. @@ -78,7 +81,7 @@ def connect_nearby_ends(digest, reverse, min_gap): path_count = len(layer_item.paths) if path_count < 2: continue # Move on to next layer - + # Inflate point by min_gap to xmin, ymin, xmax, ymax rectangular bounds point_bounds = lambda x, y: (x - min_gap, y - min_gap, x + min_gap, y + min_gap) @@ -103,14 +106,13 @@ def connect_nearby_ends(digest, reverse, min_gap): if reverse: i_start = path_i.first_point() i_matches += list(spatial_index.intersection(point_bounds(*i_start))) - + for index_maybe in i_matches: match_found = False index_j = index_maybe % path_count if index_j <= index_i: continue - j_start = layer_item.paths[index_j].first_point() if reverse: j_end = layer_item.paths[index_j].last_point() @@ -207,64 +209,46 @@ def reorder(digest, reverse): reverse (boolean) - True if paths can be reversed """ - last_point = (0, 0) # Represent starting position for a plot. - for layer_item in digest.layers: + available_count = len(layer_item.paths) - sorted_paths = [] - reserved_paths = [] - available_paths = layer_item.paths - available_count = len(available_paths) - - if available_count < 1: - continue # No paths to sort; move on to next layer - - rev_path = False # Flag: Should the current poly be reversed, if it is the best? - rev_best = False # Flag for if the "best" poly should be reversed - prev_best = None # Previous best path. Start with None on each layer - - while available_count > 0: - min_dist = 1E100 # Initialize with a large number - new_best = None # Best path thus far within the inner loop - - for path in available_paths: # INNER LOOP - best_so_far = False - start = path.first_point() - dist = plot_utils.square_dist(last_point, start) - if dist < min_dist: - best_so_far = True - min_dist = dist - rev_path = False - if reverse: - end = path.last_point() - dist_rev = plot_utils.square_dist(last_point, end) - if dist_rev < min_dist: - best_so_far = True - min_dist = dist_rev - rev_path = True - if best_so_far: - if new_best is not None: - reserved_paths.append(new_best) # Set aside "old" best path - new_best = path - rev_best = rev_path - else: - reserved_paths.append(path) # Reserve prior best for future use - # END OF INNER LOOP - - if rev_best: # We have selected the next path; reverse it if flagged to do so. - new_best.reverse() - - if prev_best is None: - prev_best = new_best # Store + if available_count <= 1: + continue # No sortable paths; move on to next layer + + tour_path = [] + + endpoints = [] + for path_reference in layer_item.paths: + endpoints.append([path_reference.first_point(), path_reference.last_point()]) + + grid_bins = 4 + math.floor(available_count / 2500) # Scale grid size with vertices + grid_index = spatial_grid.Index(endpoints, grid_bins, reverse) + + vertex = [0, 0] # Starting position of plot: (0,0) + + while True: + nearest_index = grid_index.nearest(vertex) + + if nearest_index is None: + break # Exhausted paths in the index; tour is complete + + if nearest_index >= available_count: + nearest_index -= available_count + rev_path = True + vertex = endpoints[nearest_index][0] # First vertex of selected path else: - sorted_paths.append(prev_best) # Reserve prior best for future use - prev_best = new_best + rev_path = False + vertex = endpoints[nearest_index][1] # Last vertex of selected path - last_point = new_best.last_point() + tour_path.append([nearest_index, rev_path]) - available_paths = copy.copy(reserved_paths) - available_count = len(available_paths) - reserved_paths = [] + grid_index.remove_path(nearest_index) # Exclude this path's ends from the search - sorted_paths.append(prev_best) # Add final path to our list - layer_item.paths = copy.copy(sorted_paths) + # Re-ordering is done; Update the list of paths in the layer. + output_path_temp = [] + for path_number, rev_path in tour_path: + next_path = layer_item.paths[path_number] + if rev_path: + next_path.reverse() + output_path_temp.append(next_path) + layer_item.paths = copy.copy(output_path_temp) diff --git a/inkscape driver/spatial_grid.py b/inkscape driver/spatial_grid.py new file mode 100644 index 0000000..c066e27 --- /dev/null +++ b/inkscape driver/spatial_grid.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +# spatial_grid.py +# part of plotink: https://github.com/evil-mad/plotink +# +# See below for version information +# +# Copyright (c) 2022 Windell H. Oskay, Evil Mad Scientist Laboratories +# +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +spatial_grid.py + +Specialized grid spatial index class for calculating nearest neighbors +""" + + +import math + +from axidrawinternal.plot_utils_import import from_dependency_import # plotink +plot_utils = from_dependency_import('plotink.plot_utils') + + +class Index: + ''' Grid index class ''' + grid = [] # The grid; list of path ends in each cell + adjacents = [] # Adjacency list; list of neighboring cells for each cell + lookup = [] # List of which grid cell each path end can be found inside. + path_count = 0 # Initial number of paths + vertices = None # The list of start, end vertices for each path + reverse = False # Boolean: Are path reversals allowed? + bin_size_x = 1.0 # Width of grid cells + bin_size_y = 1.0 # Height of grid cells + xmin = -1.0 # Grid bounds + ymin = -1.0 # Grid bounds + bins_per_side = 3 # Number of bins per side of the grid + + def __init__(self, vertices, bins_per_side, reverse): + ''' + Given an input list of path vertices and number of bins per side, + populate a 1D list that represents a linearized 2D grid, + bins_per_side x bins_per_side in size. Each cell contains a list that + indicates which path numbers have ends that can be found in that cell. + + Also populate an adjacency list where each cell contains a list of + which cells are that cell or its neighbors; up to 9 possible. + + And, populate a 1D "reverse" lookup list that gives the grid-cell + location of each path end. + + Input vertices is a 1D list of elements: [first_vertex, last_vertex] + for each path. Each vertex is a (x,y) tuple. + + self.bins_per_side is an integer, that once squared gives the number of grid + cells. Practical minimum of 3, for 9 squares. + + reverse is boolean, indicating whether the paths can be reversed. + ''' + + self.vertices = vertices + self.reverse = reverse + self.bins_per_side = bins_per_side + self.path_count = len(vertices) + max_bin = bins_per_side - 1 # array index of the largest x or y bin + + self.find_adjacents() + + # Calculate extent of grid: + self.xmin, self.ymin = math.inf, math.inf + xmax, ymax = -math.inf, -math.inf + + for [x_1, y_1], [x_2, y_2] in self.vertices: + self.xmin = min(self.xmin, x_1) + xmax = max(xmax, x_1) + self.ymin = min(self.ymin, y_1) + ymax = max(ymax, y_1) + if reverse: + self.xmin = min(self.xmin, x_2) + xmax = max(xmax, x_2) + self.ymin = min(self.ymin, y_2) + ymax = max(ymax, y_2) + + # Artificially increase size of grid to avoid vertices on the borders: + shim = (xmax - self.xmin + ymax - self.ymin) / 200 + self.xmin -= shim + self.ymin -= shim + xmax += shim + ymax += shim + + # Calculate bin sizes: + self.bin_size_x = (xmax - self.xmin) / bins_per_side + self.bin_size_y = (ymax - self.ymin) / bins_per_side + + # Initialize the "reverse" lookup list: + if reverse: + self.lookup = [0 for temp_var in range(2 * self.path_count)] + else: + self.lookup = [0 for temp_var in range(self.path_count)] + + # Initialize the grid, with an empty list in each cell: + self.grid = [[] for index_i in range(self.bins_per_side * self.bins_per_side)] + + for (index_i, [[x_1, y_1], [x_2, y_2]]) in enumerate(self.vertices): + x_bin = min(math.floor((x_1 - self.xmin) / self.bin_size_x), max_bin) + y_bin = min(math.floor((y_1 - self.ymin) / self.bin_size_y), max_bin) + grid_index = x_bin + self.bins_per_side * y_bin + self.grid[grid_index].append(index_i) + self.lookup[index_i] = grid_index # Which grid cell is the path start in? + + if reverse: + x_bin = min(math.floor((x_2 - self.xmin) / self.bin_size_x), max_bin) + y_bin = min(math.floor((y_2 - self.ymin) / self.bin_size_y), max_bin) + grid_index = x_bin + self.bins_per_side * y_bin + self.grid[grid_index].append(self.path_count + index_i) + self.lookup[self.path_count + index_i] = grid_index + + + def find_adjacents(self): + ''' + Also populate an adjacency list, where each cell contains a list of + which cells are that cell or its neighbors; up to 9 possible. + ''' + max_bin = self.bins_per_side - 1 + + self.adjacents = [[a_s] for a_s in range(self.bins_per_side * self.bins_per_side)] + for y_row in range(self.bins_per_side): + for x_col in range(self.bins_per_side): + index_i = x_col + y_row * (self.bins_per_side) + if x_col > 0: + self.adjacents[index_i].append(index_i - 1) # OK + if y_row > 0: + self.adjacents[index_i].append(index_i - self.bins_per_side - 1) + if y_row < max_bin: + self.adjacents[index_i].append(index_i + self.bins_per_side - 1) + if x_col < max_bin: + self.adjacents[index_i].append(index_i + 1) + if y_row > 0: + self.adjacents[index_i].append(index_i - self.bins_per_side + 1) + if y_row < max_bin: + self.adjacents[index_i].append(index_i + self.bins_per_side + 1) + if y_row > 0: + self.adjacents[index_i].append(index_i - self.bins_per_side) + if y_row < max_bin: + self.adjacents[index_i].append(index_i + self.bins_per_side) + + + def nearest(self, vertex_in): + ''' + Find the nearest path end to the given vertex and return its index. + Input last_vertex is a [x, y] list. + + Method: + * Locate which grid cell the input vertex is located in. + * For every vertex in that grid cell, plus the (up to) eight surrounding it, + check to see if it is the nearest neighbor to the input vertex. + If so, return the index of that closest vertex. + + * If there are no vertices in those (up to) 9 cells, check the entire rest of + the document, and find the closest (global) point. + + * If no vertices are found at all, return None + + The neighborhood of up 8 cells surrounding the initial cell serves as a crude + circular region surrounding the vertex. In most (but not all) cases, the + nearest point within that region will be the nearest point globally. + The precision of that "circle" could be improved by using a finer grid, and a + larger number of adjacent cells to check. + ''' + + max_bin = self.bins_per_side - 1 + + x_bin = min(math.floor((vertex_in[0] - self.xmin) / self.bin_size_x), max_bin) + y_bin = min(math.floor((vertex_in[1] - self.ymin) / self.bin_size_y), max_bin) + last_cell = max(x_bin + self.bins_per_side * y_bin, 0) + + neighborhood_cells = self.adjacents[last_cell].copy() + + best_dist = math.inf + best_index = None + for cell in neighborhood_cells: + for path_index in self.grid[cell]: + if path_index >= self.path_count: # new path is reversed + vertex = self.vertices[path_index - self.path_count][1] + else: + vertex = self.vertices[path_index][0] # Beginning of next path + + dist = plot_utils.square_dist(vertex_in, vertex) + if dist < best_dist: + best_dist = dist + best_index = path_index + if best_index: + return best_index + + # Fallback: Check remaining cells if no points were found in neighborhood: + for cell in range (len(self.adjacents)): + if cell in neighborhood_cells: + continue + for path_index in self.grid[cell]: + if path_index >= self.path_count: # new path is reversed + vertex = self.vertices[path_index - self.path_count][1] + else: + vertex = self.vertices[path_index][0] + + dist = plot_utils.square_dist(vertex_in, vertex) + if dist < best_dist: + best_dist = dist + best_index = path_index + return best_index + + + def remove_path(self, path_index): + ''' + Remove the vertex with the given path_index from the spatial index. + path_index should be less than self.path_count. + + If reversing is enabled, also remove the vertex with index + path_index + self.path_count + ''' + + cell_number = self.lookup[path_index] + self.grid[cell_number].remove(path_index) + + if not self.reverse: + return + + other_index = path_index + self.path_count + cell_number = self.lookup[other_index] + self.grid[cell_number].remove(other_index) From 185c0e24d2fefe1afeab0be3442175a1323910c5 Mon Sep 17 00:00:00 2001 From: Windell Oskay Date: Mon, 3 Jan 2022 10:21:50 -0800 Subject: [PATCH 2/4] Use larger grid when reversing --- inkscape driver/plot_optimizations.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/inkscape driver/plot_optimizations.py b/inkscape driver/plot_optimizations.py index f962abc..6f2ce9d 100644 --- a/inkscape driver/plot_optimizations.py +++ b/inkscape driver/plot_optimizations.py @@ -221,7 +221,10 @@ def reorder(digest, reverse): for path_reference in layer_item.paths: endpoints.append([path_reference.first_point(), path_reference.last_point()]) - grid_bins = 4 + math.floor(available_count / 2500) # Scale grid size with vertices + if reverse: + grid_bins = 4 + math.floor(available_count / 1200) + else: + grid_bins = 4 + math.floor(available_count / 2500) grid_index = spatial_grid.Index(endpoints, grid_bins, reverse) vertex = [0, 0] # Starting position of plot: (0,0) From 4c5ffa4ab928a98345b926afdc3b1d0a3cc47a78 Mon Sep 17 00:00:00 2001 From: Windell Oskay Date: Mon, 3 Jan 2022 10:32:28 -0800 Subject: [PATCH 3/4] Keep a roughly constant count of points per grid cell Improve scaling at high vertex count --- inkscape driver/plot_optimizations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inkscape driver/plot_optimizations.py b/inkscape driver/plot_optimizations.py index 6f2ce9d..3b1bba2 100644 --- a/inkscape driver/plot_optimizations.py +++ b/inkscape driver/plot_optimizations.py @@ -222,9 +222,9 @@ def reorder(digest, reverse): endpoints.append([path_reference.first_point(), path_reference.last_point()]) if reverse: - grid_bins = 4 + math.floor(available_count / 1200) + grid_bins = 4 + math.floor(math.sqrt(available_count / 25)) else: - grid_bins = 4 + math.floor(available_count / 2500) + grid_bins = 4 + math.floor(math.sqrt(available_count / 50)) grid_index = spatial_grid.Index(endpoints, grid_bins, reverse) vertex = [0, 0] # Starting position of plot: (0,0) From 4c738c459f52cdf233e9cd5e994f050ce4d8e948 Mon Sep 17 00:00:00 2001 From: Windell Oskay Date: Mon, 3 Jan 2022 11:01:29 -0800 Subject: [PATCH 4/4] Minor cleanup Better constraints on locating initial neighborhood around the input vertex --- inkscape driver/spatial_grid.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/inkscape driver/spatial_grid.py b/inkscape driver/spatial_grid.py index c066e27..63b402e 100644 --- a/inkscape driver/spatial_grid.py +++ b/inkscape driver/spatial_grid.py @@ -29,7 +29,7 @@ """ spatial_grid.py -Specialized grid spatial index class for calculating nearest neighbors +Specialized flat grid spatial index class for calculating nearest neighbors """ @@ -41,7 +41,7 @@ class Index: ''' Grid index class ''' - grid = [] # The grid; list of path ends in each cell + grid = [] # The grid: List of cells, each of which will contain a list of path ends adjacents = [] # Adjacency list; list of neighboring cells for each cell lookup = [] # List of which grid cell each path end can be found inside. path_count = 0 # Initial number of paths @@ -135,7 +135,7 @@ def __init__(self, vertices, bins_per_side, reverse): def find_adjacents(self): ''' - Also populate an adjacency list, where each cell contains a list of + Populate an adjacency list, where each cell contains a list of which cells are that cell or its neighbors; up to 9 possible. ''' max_bin = self.bins_per_side - 1 @@ -187,9 +187,10 @@ def nearest(self, vertex_in): max_bin = self.bins_per_side - 1 - x_bin = min(math.floor((vertex_in[0] - self.xmin) / self.bin_size_x), max_bin) - y_bin = min(math.floor((vertex_in[1] - self.ymin) / self.bin_size_y), max_bin) - last_cell = max(x_bin + self.bins_per_side * y_bin, 0) + # Use max/min to constrain the initial row and column of our first cell to check + x_bin = max(min(math.floor((vertex_in[0] - self.xmin) / self.bin_size_x), max_bin), 0) + y_bin = max(min(math.floor((vertex_in[1] - self.ymin) / self.bin_size_y), max_bin), 0) + last_cell = x_bin + self.bins_per_side * y_bin neighborhood_cells = self.adjacents[last_cell].copy() @@ -229,7 +230,7 @@ def nearest(self, vertex_in): def remove_path(self, path_index): ''' Remove the vertex with the given path_index from the spatial index. - path_index should be less than self.path_count. + Input path_index must be < self.path_count. If reversing is enabled, also remove the vertex with index path_index + self.path_count @@ -238,9 +239,7 @@ def remove_path(self, path_index): cell_number = self.lookup[path_index] self.grid[cell_number].remove(path_index) - if not self.reverse: - return - - other_index = path_index + self.path_count - cell_number = self.lookup[other_index] - self.grid[cell_number].remove(other_index) + if self.reverse: + other_index = path_index + self.path_count + cell_number = self.lookup[other_index] + self.grid[cell_number].remove(other_index)