-
Notifications
You must be signed in to change notification settings - Fork 5
/
render.py
256 lines (214 loc) · 10.1 KB
/
render.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import logging
import os
import sys
import xml.etree.ElementTree as ET
from typing import Tuple
from PIL import Image, ImageDraw, ImageFont
from PIL.Image import FLIP_TOP_BOTTOM
from utility import position_on_edge
if 'SUMO_HOME' in os.environ:
tools = os.path.join(os.environ['SUMO_HOME'], 'tools')
sys.path.append(tools)
else:
sys.exit("Please declare environment variable 'SUMO_HOME' to use sumolib")
import sumolib
COLOUR_CITY_GATE = (255, 0, 0)
COLOUR_BUS_STOP = (250, 146, 0, 128)
COLOUR_SCHOOL = (255, 0, 216, 160)
COLOUR_CENTRE = (255, 0, 0, 128)
def display_network(net: sumolib.net.Net, stats: ET.ElementTree, max_size: int, centre: Tuple[float, float],
network_name: str):
"""
:param net: the network to display noisemap for
:param stats: the stats file describing the network
:param max_size: maximum width/height of the resulting image
:param centre: the centre of the network for drawing dot
:param network_name: the name of the network for drawing in upper-left corner
:return:
"""
# Basics about the city and its size
boundary = net.getBoundary()
city_size = (boundary[2] - boundary[0], boundary[3] - boundary[1])
# Determine the size of the picture and scalars for scaling the city to the correct size
# We might have a very wide city. In this case we want to produce a wide image
width_height_relation = city_size[1] / city_size[0]
if city_size[0] > city_size[1]:
width = max_size
height = int(max_size * width_height_relation)
else:
width = int(max_size / width_height_relation)
height = max_size
width_scale = width / city_size[0]
height_scale = height / city_size[1]
def to_png_space(xy: Tuple[float, float]) -> Tuple[float, float]:
""" Translate the given city position to a png position """
return (xy[0] - boundary[0]) * width_scale, (xy[1] - boundary[1]) * height_scale
# Load pretty fonts for Linux and Windows, falling back to defaults
fontsize = max(max_size // 90, 10)
try:
font = ImageFont.truetype("LiberationMono-Regular.ttf", size=fontsize)
except IOError:
try:
font = ImageFont.truetype("arial.ttf", size=fontsize)
except IOError:
logging.warning("[render] Could not load font, falling back to default")
font = ImageFont.load_default()
assert font is not None, "[render] No font loaded, cannot continue"
# Make image and prepare for drawing
img = Image.new("RGB", (width, height), (255, 255, 255))
draw = ImageDraw.Draw(img, "RGBA")
# Draw streets
if stats.find("streets") is not None:
for street_xml in stats.find("streets").findall("street"):
edge = net.getEdge(street_xml.attrib["edge"])
population = float(street_xml.attrib["population"])
industry = float(street_xml.attrib["workPosition"])
green = int(10 + 245 * (1 - industry))
blue = int(10 + 245 * (1 - population))
for pos1, pos2 in [edge.getShape()[i:i + 2] for i in range(0, int(len(edge.getShape()) - 1))]:
draw.line((to_png_space(pos1), to_png_space(pos2)), (0, green, blue),
int(1.5 + 3.5 * population ** 1.5))
else:
logging.warning(f"[render] Could not find any streets in statistics")
# Draw city gates
if stats.find("cityGates") is not None:
for gate_xml in stats.find("cityGates").findall("entrance"):
edge = net.getEdge(gate_xml.attrib["edge"])
traffic = max(float(gate_xml.attrib["incoming"]), float(gate_xml.attrib["outgoing"]))
x, y = to_png_space(position_on_edge(edge, float(gate_xml.attrib["pos"])))
r = int(max_size / 600 + traffic / 1.3)
draw.ellipse((x - r, y - r, x + r, y + r), fill=COLOUR_CITY_GATE)
else:
logging.warning(f"[render] Could not find any city-gates in statistics")
# Draw bus stops
if stats.find("busStations") is not None:
for stop_xml in stats.find("busStations").findall("busStation"):
edge = net.getEdge(stop_xml.attrib["edge"])
x, y = to_png_space(position_on_edge(edge, float(stop_xml.attrib["pos"])))
r = max_size / 600
draw.ellipse((x - r, y - r, x + r, y + r), fill=COLOUR_BUS_STOP)
else:
logging.warning(f"[render] Could not find any bus-stations in statistics")
# Draw schools
if stats.find("schools") is not None:
for school_xml in stats.find("schools").findall("school"):
edge = net.getEdge(school_xml.attrib["edge"])
capacity = int(school_xml.get('capacity'))
x, y = to_png_space(position_on_edge(edge, float(school_xml.get('pos'))))
r = int((max_size / 275 * (capacity / 500) ** 0.4) * 1.1)
draw.ellipse((x - r, y - r, x + r, y + r), fill=COLOUR_SCHOOL)
else:
logging.warning(f"[render] Could not find any schools in statistics")
if not any([stats.find(x) for x in {"streets", "cityGates", "busStations", "schools"}]):
logging.error("[render] No elements found in statistics, cannot display network and features")
exit(1)
# Draw city centre
x, y = to_png_space(centre)
r = max_size / 100
draw.ellipse((x - r, y - r, x + r, y + r), fill=COLOUR_CENTRE)
# Flip image on the horizontal axis and update draw-pointer
img = img.transpose(FLIP_TOP_BOTTOM)
draw = ImageDraw.Draw(img, "RGBA")
Legend(max_size, height, draw, font) \
.draw_network_name(network_name) \
.draw_scale_legend(city_size, width_scale) \
.draw_gradient("Pop, work gradient") \
.draw_icon_legend(COLOUR_CENTRE, "Centre") \
.draw_icon_legend(COLOUR_SCHOOL, "School") \
.draw_icon_legend(COLOUR_BUS_STOP, "Bus stop") \
.draw_icon_legend(COLOUR_CITY_GATE, "City gate")
img.show()
class Legend:
def __init__(self, scale, height, draw, font, margin=10):
self.offset = margin
self.scale = scale / 800
self.legend_height = 10 * self.scale
self.y = height - self.legend_height - margin
self.draw: ImageDraw.ImageDraw = draw
self.font = font
def draw_icon_legend(self, colour, text):
"""
Draws a box with circular, colored dot with text on legend
:param colour: the colour of the dot
:param text: the text explaining the colour's significance
:return: self
"""
# Draw box and icon from beginning of offset
x_icon, y_icon = self.offset, self.y
width = self.legend_height
# White background
self.draw.rectangle((x_icon, y_icon, x_icon + width, y_icon + width), "#ffffff")
# Draw circle
r = width // 2
x_box_centre, y_box_centre = x_icon + r, y_icon + r
self.draw.ellipse((x_box_centre - r, y_box_centre - r, x_box_centre + r, y_box_centre + r), colour)
# Draw box
self.draw.rectangle((x_icon, y_icon, x_icon + width, y_icon + width), outline="#000000", width=int(self.scale))
self.offset += int(1.5 * width)
# Draw text
self.draw.text((self.offset, self.y), text, "#000000", font=self.font)
# Update offset
self.offset += self.font.getsize(text=text)[0] + 2 * width
return self
def draw_gradient(self, text):
"""
Draws a gradient icon-box. Colours are hard-coded to green vs blue.
Either colour is limited to min 35/255 intensity, as that's how streets are drawn.
:param text: the text explaining the gradient
:return: self
"""
# Define box dimensions
h_box = int(self.legend_height)
w_box = h_box * 2
for x in range(1, w_box):
for y in range(1, h_box):
x_intensity = 1 - x / w_box
y_intensity = y / h_box
point_colour = (0, int(10 + 245 * x_intensity), int(10 + 245 * y_intensity))
self.draw.point((self.offset + x, self.y + y), point_colour)
# draw box
self.draw.rectangle((self.offset, self.y, self.offset + w_box, self.y + h_box),
None, "#000000ff", width=int(self.scale))
self.offset += w_box + int(0.5 * self.legend_height)
# draw text
self.draw.text((self.offset, self.y), text, "#000000", font=self.font)
self.offset += self.font.getsize(text=text)[0] + 2 * self.legend_height
return self
def draw_scale_legend(self, city_size, width_scale):
"""
Draws a scale with a 'nice' resolution and units
:param city_size:
:param width_scale:
:return: self
"""
meters = find_dist_legend_size(max(city_size))
width = int(meters * width_scale)
line_y = self.y + self.legend_height // 2
# line
self.draw.line([self.offset, line_y, self.offset + width, line_y], (0, 0, 0), int(self.scale))
# ticks
self.draw.line([self.offset, self.y, self.offset, self.y + self.legend_height], (0, 0, 0), int(self.scale))
self.draw.line([self.offset + width, self.y, self.offset + width, self.y + self.legend_height], (0, 0, 0),
int(self.scale))
self.draw.text([self.offset + 5 * int(self.scale), self.y - 8 * int(self.scale)], f"{meters} m", (0, 0, 0),
font=self.font)
# add padding
self.offset += width + 2 * self.legend_height
return self
def draw_network_name(self, name):
self.draw.text((2, 2), name, fill="#000000", font=self.font)
return self
def find_dist_legend_size(real_size, frac: float = 0.2):
"""
Returns a nice number that closely matches the fraction of the real size
"""
# A "nice number" is a number equal to s * 10^n where n is an integer and s is one of the scales from this list:
scales = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.5]
# Iterate n until the nice number is greater than real_size * frac
meters = 10
while meters < real_size * frac:
for s in scales:
if meters * s > real_size * frac:
return int(meters * s)
meters *= 10
return meters