diff --git a/src/c3nav/routing/graph.py b/src/c3nav/routing/graph.py deleted file mode 100644 index bdad2655..00000000 --- a/src/c3nav/routing/graph.py +++ /dev/null @@ -1,236 +0,0 @@ -import os -from itertools import combinations, permutations - -import numpy as np -from django.conf import settings -from django.utils.functional import cached_property -from matplotlib.path import Path -from PIL import Image, ImageDraw -from shapely.geometry import JOIN_STYLE, LineString, Polygon - -from c3nav.mapdata.models import Level -from c3nav.routing.utils import assert_multipolygon, get_coords_angles, get_nearest_point, polygon_to_mpl_paths - - -class GraphLevel(): - def __init__(self, graph, level): - self.graph = graph - self.level = level - self.rooms = [] - - def build(self): - self.collect_rooms() - self.create_points() - - def collect_rooms(self): - accessibles = self.level.geometries.accessible - accessibles = [accessibles] if isinstance(accessibles, Polygon) else accessibles.geoms - for geometry in accessibles: - room = GraphRoom(self, geometry) - if not room.empty: - self.rooms.append(room) - - def create_points(self): - for room in self.rooms: - room.create_points() - - doors = self.level.geometries.doors - doors = assert_multipolygon(doors) - for door in doors: - polygon = door.buffer(0.01, join_style=JOIN_STYLE.mitre) - center = door.centroid - points = [] - for room in self.rooms: - if polygon.intersects(room.geometry): - nearest_point = get_nearest_point(room.clear_geometry, center) - point = GraphPoint(room, *nearest_point.coords[0]) - points.append(point) - room.points.append(point) - - if len(points) < 2: - print('door with <2 rooms (%d) detected!' % len(points)) - - for from_point, to_point in permutations(points, 2): - from_point.connect_to(to_point) - - for room in self.rooms: - room.connect_points() - - def _ellipse_bbox(self, x, y, height): - x *= settings.RENDER_SCALE - y *= settings.RENDER_SCALE - y = height-y - return ((x - 2, y - 2), (x + 2, y + 2)) - - def _line_coords(self, from_point, to_point, height): - return (from_point.x * settings.RENDER_SCALE, height - (from_point.y * settings.RENDER_SCALE), - to_point.x * settings.RENDER_SCALE, height - (to_point.y * settings.RENDER_SCALE)) - - def draw_png(self): - filename = os.path.join(settings.RENDER_ROOT, 'level-%s.png' % self.level.name) - graph_filename = os.path.join(settings.RENDER_ROOT, 'level-%s-graph.png' % self.level.name) - - im = Image.open(filename) - height = im.size[1] - draw = ImageDraw.Draw(im) - i = 0 - for room in self.rooms: - for point in room.points: - for otherpoint, connection in point.connections.items(): - draw.line(self._line_coords(point, otherpoint, height), fill=(255, 100, 100)) - - for point in room.points: - i += 1 - draw.ellipse(self._ellipse_bbox(point.x, point.y, height), (200, 0, 0)) - - print(i, 'points') - - im.save(graph_filename) - - -class GraphRoom(): - def __init__(self, level, geometry): - self.level = level - self.geometry = geometry - self.points = [] - - self.clear_geometry = geometry.buffer(-0.3, join_style=JOIN_STYLE.mitre) - self.empty = self.clear_geometry.is_empty - - if not self.empty: - self.mpl_paths = polygon_to_mpl_paths(self.clear_geometry.buffer(0.01, join_style=JOIN_STYLE.mitre)) - - def create_points(self): - original_geometry = self.geometry - geometry = original_geometry.buffer(-0.6, join_style=JOIN_STYLE.mitre) - - if geometry.is_empty: - return - - # points with 60cm distance to borders - polygons = assert_multipolygon(geometry) - for polygon in polygons: - self._add_ring(polygon.exterior, want_left=False) - - for interior in polygon.interiors: - self._add_ring(interior, want_left=True) - - # now fill in missing doorways or similar - missing_geometry = self.clear_geometry.difference(geometry.buffer(0.61, join_style=JOIN_STYLE.mitre)) - polygons = assert_multipolygon(missing_geometry) - for polygon in polygons: - overlaps = polygon.buffer(0.62).intersection(geometry) - if overlaps.is_empty: - continue - - points = [] - - # overlaps to non-missing areas - overlaps = assert_multipolygon(overlaps) - for overlap in overlaps: - points.append(self.add_point(overlap.centroid.coords[0])) - - points += self._add_ring(polygon.exterior, want_left=False) - - for interior in polygon.interiors: - points += self._add_ring(interior, want_left=True) - - for from_point, to_point in permutations(points, 2): - from_point.connect_to(to_point) - - def _add_ring(self, geom, want_left): - """ - add the points of a ring, but only those that have a specific direction change. - additionally removes unneeded points if the neighbors can be connected in self.clear_geometry - :param geom: LinearRing - :param want_left: True if the direction has to be left, False if it has to be right - """ - coords = [] - skipped = False - can_delete_last = False - for coord, is_left in get_coords_angles(geom): - if is_left != want_left: - skipped = True - continue - - if not skipped and can_delete_last and len(coords) >= 2: - if LineString((coords[-2], coord)).within(self.clear_geometry): - coords[-1] = coord - continue - - coords.append(coord) - can_delete_last = not skipped - skipped = False - - if not skipped and can_delete_last and len(coords) >= 3: - if LineString((coords[-2], coords[0])).within(self.clear_geometry): - coords.pop() - - points = [] - for coord in coords: - points.append(self.add_point(coord)) - - return points - - def add_point(self, coord): - point = GraphPoint(self, *coord) - self.points.append(point) - return point - - def connect_points(self): - room_paths = self.mpl_paths - for point1, point2 in combinations(self.points, 2): - path = Path(np.vstack((point1.xy, point2.xy))) - for room_path in room_paths: - if room_path.intersects_path(path, False): - break - else: - point1.connect_to(point2) - point2.connect_to(point1) - - -class GraphPoint(): - def __init__(self, room, x, y): - self.room = room - self.x = x - self.y = y - self.xy = (x, y) - self.connections = {} - self.connections_in = {} - - @cached_property - def ellipse_bbox(self): - x = self.x * settings.RENDER_SCALE - y = self.y * settings.RENDER_SCALE - return ((x-5, y-5), (x+5, y+5)) - - def connect_to(self, to_point): - self.room.level.graph.add_connection(self, to_point) - - -class GraphConnection(): - def __init__(self, graph, from_point, to_point): - self.graph = graph - - if to_point in from_point.connections: - self.graph.connections.remove(from_point.connections[to_point]) - - from_point.connections[to_point] = self - to_point.connections_in[from_point] = self - - -class Graph(): - def __init__(self): - self.levels = {} - self.connections = [] - - def build(self): - for level in Level.objects.all(): - self.levels[level.name] = GraphLevel(self, level) - - for level in self.levels.values(): - level.build() - level.draw_png() - - def add_connection(self, from_point, to_point): - self.connections.append(GraphConnection(self, from_point, to_point)) diff --git a/src/c3nav/routing/graph/__init__.py b/src/c3nav/routing/graph/__init__.py new file mode 100644 index 00000000..899cef4c --- /dev/null +++ b/src/c3nav/routing/graph/__init__.py @@ -0,0 +1 @@ +from c3nav.routing.graph.graph import Graph # noqa diff --git a/src/c3nav/routing/graph/connection.py b/src/c3nav/routing/graph/connection.py new file mode 100644 index 00000000..be5148e3 --- /dev/null +++ b/src/c3nav/routing/graph/connection.py @@ -0,0 +1,9 @@ +class GraphConnection(): + def __init__(self, graph, from_point, to_point): + self.graph = graph + + if to_point in from_point.connections: + self.graph.connections.remove(from_point.connections[to_point]) + + from_point.connections[to_point] = self + to_point.connections_in[from_point] = self diff --git a/src/c3nav/routing/graph/graph.py b/src/c3nav/routing/graph/graph.py new file mode 100644 index 00000000..cb94cd79 --- /dev/null +++ b/src/c3nav/routing/graph/graph.py @@ -0,0 +1,20 @@ +from c3nav.mapdata.models import Level +from c3nav.routing.graph.connection import GraphConnection +from c3nav.routing.graph.level import GraphLevel + + +class Graph(): + def __init__(self): + self.levels = {} + self.connections = [] + + def build(self): + for level in Level.objects.all(): + self.levels[level.name] = GraphLevel(self, level) + + for level in self.levels.values(): + level.build() + level.draw_png() + + def add_connection(self, from_point, to_point): + self.connections.append(GraphConnection(self, from_point, to_point)) diff --git a/src/c3nav/routing/graph/level.py b/src/c3nav/routing/graph/level.py new file mode 100644 index 00000000..5e25a983 --- /dev/null +++ b/src/c3nav/routing/graph/level.py @@ -0,0 +1,77 @@ +import os +from itertools import permutations + +from django.conf import settings +from PIL import Image, ImageDraw +from shapely.geometry import JOIN_STYLE, Polygon + +from c3nav.routing.graph.point import GraphPoint +from c3nav.routing.graph.room import GraphRoom +from c3nav.routing.utils.base import assert_multipolygon, get_nearest_point +from c3nav.routing.utils.draw import _ellipse_bbox, _line_coords + + +class GraphLevel(): + def __init__(self, graph, level): + self.graph = graph + self.level = level + self.rooms = [] + + def build(self): + self.collect_rooms() + self.create_points() + + def collect_rooms(self): + accessibles = self.level.geometries.accessible + accessibles = [accessibles] if isinstance(accessibles, Polygon) else accessibles.geoms + for geometry in accessibles: + room = GraphRoom(self, geometry) + if not room.empty: + self.rooms.append(room) + + def create_points(self): + for room in self.rooms: + room.create_points() + + doors = self.level.geometries.doors + doors = assert_multipolygon(doors) + for door in doors: + polygon = door.buffer(0.01, join_style=JOIN_STYLE.mitre) + center = door.centroid + points = [] + for room in self.rooms: + if polygon.intersects(room.geometry): + nearest_point = get_nearest_point(room.clear_geometry, center) + point = GraphPoint(room, *nearest_point.coords[0]) + points.append(point) + room.points.append(point) + + if len(points) < 2: + print('door with <2 rooms (%d) detected!' % len(points)) + + for from_point, to_point in permutations(points, 2): + from_point.connect_to(to_point) + + for room in self.rooms: + room.connect_points() + + def draw_png(self): + filename = os.path.join(settings.RENDER_ROOT, 'level-%s.png' % self.level.name) + graph_filename = os.path.join(settings.RENDER_ROOT, 'level-%s-graph.png' % self.level.name) + + im = Image.open(filename) + height = im.size[1] + draw = ImageDraw.Draw(im) + i = 0 + for room in self.rooms: + for point in room.points: + for otherpoint, connection in point.connections.items(): + draw.line(_line_coords(point, otherpoint, height), fill=(255, 100, 100)) + + for point in room.points: + i += 1 + draw.ellipse(_ellipse_bbox(point.x, point.y, height), (200, 0, 0)) + + print(i, 'points') + + im.save(graph_filename) diff --git a/src/c3nav/routing/graph/point.py b/src/c3nav/routing/graph/point.py new file mode 100644 index 00000000..e4fa6d21 --- /dev/null +++ b/src/c3nav/routing/graph/point.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.utils.functional import cached_property + + +class GraphPoint(): + def __init__(self, room, x, y): + self.room = room + self.x = x + self.y = y + self.xy = (x, y) + self.connections = {} + self.connections_in = {} + + @cached_property + def ellipse_bbox(self): + x = self.x * settings.RENDER_SCALE + y = self.y * settings.RENDER_SCALE + return ((x-5, y-5), (x+5, y+5)) + + def connect_to(self, to_point): + self.room.level.graph.add_connection(self, to_point) diff --git a/src/c3nav/routing/graph/room.py b/src/c3nav/routing/graph/room.py new file mode 100644 index 00000000..4bbb6163 --- /dev/null +++ b/src/c3nav/routing/graph/room.py @@ -0,0 +1,111 @@ +from itertools import combinations, permutations + +import numpy as np +from matplotlib.path import Path +from shapely.geometry import JOIN_STYLE, LineString + +from c3nav.routing.graph.point import GraphPoint +from c3nav.routing.utils.base import assert_multipolygon +from c3nav.routing.utils.coords import get_coords_angles +from c3nav.routing.utils.mpl import polygon_to_mpl_paths + + +class GraphRoom(): + def __init__(self, level, geometry): + self.level = level + self.geometry = geometry + self.points = [] + + self.clear_geometry = geometry.buffer(-0.3, join_style=JOIN_STYLE.mitre) + self.empty = self.clear_geometry.is_empty + + if not self.empty: + self.mpl_paths = polygon_to_mpl_paths(self.clear_geometry.buffer(0.01, join_style=JOIN_STYLE.mitre)) + + def create_points(self): + original_geometry = self.geometry + geometry = original_geometry.buffer(-0.6, join_style=JOIN_STYLE.mitre) + + if geometry.is_empty: + return + + # points with 60cm distance to borders + polygons = assert_multipolygon(geometry) + for polygon in polygons: + self._add_ring(polygon.exterior, want_left=False) + + for interior in polygon.interiors: + self._add_ring(interior, want_left=True) + + # now fill in missing doorways or similar + missing_geometry = self.clear_geometry.difference(geometry.buffer(0.61, join_style=JOIN_STYLE.mitre)) + polygons = assert_multipolygon(missing_geometry) + for polygon in polygons: + overlaps = polygon.buffer(0.62).intersection(geometry) + if overlaps.is_empty: + continue + + points = [] + + # overlaps to non-missing areas + overlaps = assert_multipolygon(overlaps) + for overlap in overlaps: + points.append(self.add_point(overlap.centroid.coords[0])) + + points += self._add_ring(polygon.exterior, want_left=False) + + for interior in polygon.interiors: + points += self._add_ring(interior, want_left=True) + + for from_point, to_point in permutations(points, 2): + from_point.connect_to(to_point) + + def _add_ring(self, geom, want_left): + """ + add the points of a ring, but only those that have a specific direction change. + additionally removes unneeded points if the neighbors can be connected in self.clear_geometry + :param geom: LinearRing + :param want_left: True if the direction has to be left, False if it has to be right + """ + coords = [] + skipped = False + can_delete_last = False + for coord, is_left in get_coords_angles(geom): + if is_left != want_left: + skipped = True + continue + + if not skipped and can_delete_last and len(coords) >= 2: + if LineString((coords[-2], coord)).within(self.clear_geometry): + coords[-1] = coord + continue + + coords.append(coord) + can_delete_last = not skipped + skipped = False + + if not skipped and can_delete_last and len(coords) >= 3: + if LineString((coords[-2], coords[0])).within(self.clear_geometry): + coords.pop() + + points = [] + for coord in coords: + points.append(self.add_point(coord)) + + return points + + def add_point(self, coord): + point = GraphPoint(self, *coord) + self.points.append(point) + return point + + def connect_points(self): + room_paths = self.mpl_paths + for point1, point2 in combinations(self.points, 2): + path = Path(np.vstack((point1.xy, point2.xy))) + for room_path in room_paths: + if room_path.intersects_path(path, False): + break + else: + point1.connect_to(point2) + point2.connect_to(point1) diff --git a/src/c3nav/routing/utils.py b/src/c3nav/routing/utils.py deleted file mode 100644 index a8fef95c..00000000 --- a/src/c3nav/routing/utils.py +++ /dev/null @@ -1,128 +0,0 @@ -from math import atan2, degrees - -from matplotlib.path import Path -from shapely.geometry import Polygon - - -def cleanup_coords(coords): - """ - remove coordinates that are closer than 0.01 (1cm) - :param coords: list of (x, y) coordinates - :return: list of (x, y) coordinates - """ - result = [] - last_coord = coords[-1] - for coord in coords: - if ((coord[0] - last_coord[0]) ** 2 + (coord[1] - last_coord[1]) ** 2) ** 0.5 >= 0.01: - result.append(coord) - last_coord = coord - return result - - -def coord_angle(coord1, coord2): - """ - calculate angle in degrees from coord1 to coord2 - :param coord1: (x, y) coordinate - :param coord2: (x, y) coordinate - :return: angle in degrees - """ - return degrees(atan2(-(coord2[1] - coord1[1]), coord2[0] - coord1[0])) % 360 - - -def get_coords_angles(geom): - """ - inspects all coordinates of a LinearRing counterclockwise and checks if they are a left or a right turn. - :param geom: LinearRing - :rtype: a list of ((x, y), is_left) tuples - """ - coords = list(cleanup_coords(geom.coords)) - last_coords = coords[-2:] - last_angle = coord_angle(last_coords[-2], last_coords[-1]) - result = [] - - invert = not geom.is_ccw - - for coord in coords: - angle = coord_angle(last_coords[-1], coord) - angle_diff = (last_angle-angle) % 360 - result.append((last_coords[-1], (angle_diff < 180) ^ invert)) - last_coords.append(coord) - last_angle = angle - - return result - - -def polygon_to_mpl_paths(polygon): - """ - convert a shapely Polygon or Multipolygon to a matplotlib Path - :param polygon: shapely Polygon or Multipolygon - :return: matplotlib Path - """ - paths = [] - for polygon in assert_multipolygon(polygon): - paths.append(linearring_to_mpl_path(polygon.exterior)) - for interior in polygon.interiors: - paths.append(linearring_to_mpl_path(interior)) - return paths - - -def linearring_to_mpl_path(linearring): - vertices = [] - codes = [] - coords = list(linearring.coords) - vertices.extend(coords) - vertices.append(coords[0]) - codes.append(Path.MOVETO) - codes.extend([Path.LINETO] * (len(coords)-1)) - codes.append(Path.CLOSEPOLY) - return Path(vertices, codes, readonly=True) - - -def assert_multipolygon(geometry): - """ - given a Polygon or a MultiPolygon, return a list of Polygons - :param geometry: a Polygon or a MultiPolygon - :return: a list of Polygons - """ - if isinstance(geometry, Polygon): - polygons = [geometry] - else: - polygons = geometry.geoms - return polygons - - -def get_nearest_point(polygon, point): - """ - calculate the nearest point on of a polygon to another point that lies outside - :param polygon: a Polygon or a MultiPolygon - :param point: something that shapely understands as a point - :return: a Point - """ - polygons = assert_multipolygon(polygon) - nearest_distance = float('inf') - nearest_point = None - for polygon in polygons: - if point.within(Polygon(polygon.exterior.coords)): - for interior in polygon.interiors: - if point.within(Polygon(interior.coords)): - point_ = _nearest_point_ring(interior, point) - distance = point_.distance(point) - if distance and distance < nearest_distance: - nearest_distance = distance - nearest_point = point_ - break # in a valid polygon a point can not be within multiple interiors - break # in a valid multipolygon a point can not be within multiple polygons - else: - point_ = _nearest_point_ring(polygon.exterior, point) - distance = point_.distance(point) - if distance and distance < nearest_distance: - nearest_distance = distance - nearest_point = point_ - - if nearest_point is None: - raise ValueError('Point inside polygon.') - return nearest_point - - -def _nearest_point_ring(ring, point): - return ring.interpolate(ring.project(point)) diff --git a/src/c3nav/routing/utils/__init__.py b/src/c3nav/routing/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/c3nav/routing/utils/base.py b/src/c3nav/routing/utils/base.py new file mode 100644 index 00000000..a69bf6ea --- /dev/null +++ b/src/c3nav/routing/utils/base.py @@ -0,0 +1,51 @@ +from shapely.geometry import Polygon + + +def assert_multipolygon(geometry): + """ + given a Polygon or a MultiPolygon, return a list of Polygons + :param geometry: a Polygon or a MultiPolygon + :return: a list of Polygons + """ + if isinstance(geometry, Polygon): + polygons = [geometry] + else: + polygons = geometry.geoms + return polygons + + +def get_nearest_point(polygon, point): + """ + calculate the nearest point on of a polygon to another point that lies outside + :param polygon: a Polygon or a MultiPolygon + :param point: something that shapely understands as a point + :return: a Point + """ + polygons = assert_multipolygon(polygon) + nearest_distance = float('inf') + nearest_point = None + for polygon in polygons: + if point.within(Polygon(polygon.exterior.coords)): + for interior in polygon.interiors: + if point.within(Polygon(interior.coords)): + point_ = _nearest_point_ring(interior, point) + distance = point_.distance(point) + if distance and distance < nearest_distance: + nearest_distance = distance + nearest_point = point_ + break # in a valid polygon a point can not be within multiple interiors + break # in a valid multipolygon a point can not be within multiple polygons + else: + point_ = _nearest_point_ring(polygon.exterior, point) + distance = point_.distance(point) + if distance and distance < nearest_distance: + nearest_distance = distance + nearest_point = point_ + + if nearest_point is None: + raise ValueError('Point inside polygon.') + return nearest_point + + +def _nearest_point_ring(ring, point): + return ring.interpolate(ring.project(point)) diff --git a/src/c3nav/routing/utils/coords.py b/src/c3nav/routing/utils/coords.py new file mode 100644 index 00000000..67e9ecbb --- /dev/null +++ b/src/c3nav/routing/utils/coords.py @@ -0,0 +1,49 @@ +from math import atan2, degrees + + +def cleanup_coords(coords): + """ + remove coordinates that are closer than 0.01 (1cm) + :param coords: list of (x, y) coordinates + :return: list of (x, y) coordinates + """ + result = [] + last_coord = coords[-1] + for coord in coords: + if ((coord[0] - last_coord[0]) ** 2 + (coord[1] - last_coord[1]) ** 2) ** 0.5 >= 0.01: + result.append(coord) + last_coord = coord + return result + + +def coord_angle(coord1, coord2): + """ + calculate angle in degrees from coord1 to coord2 + :param coord1: (x, y) coordinate + :param coord2: (x, y) coordinate + :return: angle in degrees + """ + return degrees(atan2(-(coord2[1] - coord1[1]), coord2[0] - coord1[0])) % 360 + + +def get_coords_angles(geom): + """ + inspects all coordinates of a LinearRing counterclockwise and checks if they are a left or a right turn. + :param geom: LinearRing + :rtype: a list of ((x, y), is_left) tuples + """ + coords = list(cleanup_coords(geom.coords)) + last_coords = coords[-2:] + last_angle = coord_angle(last_coords[-2], last_coords[-1]) + result = [] + + invert = not geom.is_ccw + + for coord in coords: + angle = coord_angle(last_coords[-1], coord) + angle_diff = (last_angle-angle) % 360 + result.append((last_coords[-1], (angle_diff < 180) ^ invert)) + last_coords.append(coord) + last_angle = angle + + return result diff --git a/src/c3nav/routing/utils/draw.py b/src/c3nav/routing/utils/draw.py new file mode 100644 index 00000000..b278e2a0 --- /dev/null +++ b/src/c3nav/routing/utils/draw.py @@ -0,0 +1,13 @@ +from django.conf import settings + + +def _ellipse_bbox(x, y, height): + x *= settings.RENDER_SCALE + y *= settings.RENDER_SCALE + y = height-y + return ((x - 2, y - 2), (x + 2, y + 2)) + + +def _line_coords(from_point, to_point, height): + return (from_point.x * settings.RENDER_SCALE, height - (from_point.y * settings.RENDER_SCALE), + to_point.x * settings.RENDER_SCALE, height - (to_point.y * settings.RENDER_SCALE)) diff --git a/src/c3nav/routing/utils/mpl.py b/src/c3nav/routing/utils/mpl.py new file mode 100644 index 00000000..6b7401ab --- /dev/null +++ b/src/c3nav/routing/utils/mpl.py @@ -0,0 +1,29 @@ +from matplotlib.path import Path + +from c3nav.routing.utils.base import assert_multipolygon + + +def polygon_to_mpl_paths(polygon): + """ + convert a shapely Polygon or Multipolygon to a matplotlib Path + :param polygon: shapely Polygon or Multipolygon + :return: matplotlib Path + """ + paths = [] + for polygon in assert_multipolygon(polygon): + paths.append(linearring_to_mpl_path(polygon.exterior)) + for interior in polygon.interiors: + paths.append(linearring_to_mpl_path(interior)) + return paths + + +def linearring_to_mpl_path(linearring): + vertices = [] + codes = [] + coords = list(linearring.coords) + vertices.extend(coords) + vertices.append(coords[0]) + codes.append(Path.MOVETO) + codes.extend([Path.LINETO] * (len(coords)-1)) + codes.append(Path.CLOSEPOLY) + return Path(vertices, codes, readonly=True)