-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdaylen.py
More file actions
executable file
·321 lines (238 loc) · 8.5 KB
/
daylen.py
File metadata and controls
executable file
·321 lines (238 loc) · 8.5 KB
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
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
#!/usr/bin/env python3
import colorsys
import datetime
import functools
import math
import os.path
import sys
import astral
from PIL import Image, ImageDraw, ImageFont
IMAGE_FN = "map.png"
FONT_FN = "/usr/share/fonts/TTF/calibri.ttf"
MAP_CENTER_X = 998
MAP_CENTER_Y = 293
DEGREE_TO_PX = 13.35
MIN_LATITUDE = 25
MAX_LATITUDE = 90
MIN_SEPARATION_DEG = 2.5
GRAD_START_COLOR = (160, 0, 0)
GRAD_END_COLOR = (0, 160, 0)
USE_DAWNDUSK = False
ANIMATE = True
ANIMATE_OUTDIR = "out"
DEBUG = False
# Main pipeline
def animate():
for month in range(1, 13):
for day in range(1, 32):
output_animation_frame(month, day)
def output_animation_frame(month, day):
try:
date = datetime.date(2016, month, day)
except ValueError:
return
fn = os.path.join(ANIMATE_OUTDIR, "{:02d}-{:02d}.png".format(month, day))
map_img = Image.open(IMAGE_FN)
try:
open(fn, mode="w").close()
except OSError as e:
prerr("Cannot open {} for writing: {}".format(fn, e))
sys.exit(1)
print("outputting to", fn)
img = apply_isolines_to_image(map_img, date)
img.save(fn)
def apply_isolines_to_image(img, date):
daylen = 0
daylen_incr = 0.5
last_lat = None
# If summer, start iterating from isoline 24 to ensure the polar day
# region is marked. Same in winter for polar night.
if (is_summer(date)):
daylen = 24
daylen_incr = -daylen_incr
# Loop over isolines and apply to image if the separation between isolines
# is sufficient and latitudes are not extremely polar.
while (daylen <= 24 and daylen >= 0):
lat = get_isoline_latitude(date, daylen)
if (not lat or (lat and
(last_lat and abs(last_lat - lat) < MIN_SEPARATION_DEG) or (lat > 85)
)):
daylen += daylen_incr
continue
color = \
get_hsl_gradient_point(daylen, 0, 24, GRAD_START_COLOR, GRAD_END_COLOR)
img = \
outline_latitude(img, lat, string=str(daylen), color=color, font_size=40)
prerr("{}: Applied isoline {} at latitude {:.2f}".format(date, daylen, lat))
last_lat = lat
daylen += daylen_incr
print_date(img, date, font_size=72)
return img
def get_isoline_latitude(date, daylen):
""" Run a binary search for day length isoline latitude. Uses a
convergence guard to work around shortcomings of the astral module:
extremely short or long day isolines in polar regions will cause
recursion errors otherwise. In summer, high isoline values correspond to
high latitudes; the reverse in winter.
"""
precision = 0.001
summer = is_summer(date)
convergence = []
def binsearch_latitude(low_lat, high_lat, target_dl):
mid_lat = low_lat + (high_lat - low_lat) / 2
low_dl = get_daylen_on_latitude(date, low_lat)
mid_dl = get_daylen_on_latitude(date, mid_lat)
high_dl = get_daylen_on_latitude(date, high_lat)
convergence.append(mid_lat)
if (summer and (low_dl > target_dl)):
return None
elif (not summer and (low_dl < target_dl)):
return None
if (
len(convergence) >= 3
and abs(convergence[-1] - convergence[-2]) <= precision
and abs(convergence[-2] - convergence[-3]) <= precision
):
return mid_lat
if (abs(low_dl - target_dl) <= precision):
return low_lat
elif (abs(mid_dl - target_dl) <= precision):
return mid_lat
elif (abs(high_dl - target_dl) <= precision):
return high_lat
if (summer and low_dl <= target_dl and target_dl < mid_dl):
return binsearch_latitude(low_lat, mid_lat, target_dl)
elif (not summer and low_dl >= target_dl and target_dl > mid_dl):
return binsearch_latitude(low_lat, mid_lat, target_dl)
else:
return binsearch_latitude(mid_lat, high_lat, target_dl)
return binsearch_latitude(MIN_LATITUDE, MAX_LATITUDE, daylen)
@functools.lru_cache(maxsize=2048)
def get_daylen_on_latitude(date, lat):
""" Memoized latitude to day length function, returns day length in hours. """
loc = astral.Location()
loc.latitude = lat
loc.longitude = 0
loc.solar_depression = "civil"
try:
(start, end) = loc.daylight(date=date, local=False) \
if (not USE_DAWNDUSK) \
else (loc.dawn(date=date, local=False), loc.dusk(date=date, local=False))
except astral.AstralError as e:
if (is_summer(date)):
return math.inf
return -math.inf
return (end - start).total_seconds() / 3600
def outline_latitude(img, latitude, string="", font_size=20, thickness=3, color=(200, 0, 0)):
""" Outline a latitude with a circle of given thickness and color. Uses
a mask layer to overcome the lack of a thickness option in PIL's ellipse
method.
"""
r = (90 - latitude) * DEGREE_TO_PX
center_x = MAP_CENTER_X
center_y = MAP_CENTER_Y
font = ImageFont.truetype(FONT_FN, font_size)
(text_w, text_h) = font.getsize(string)
# Create mask + composite images
ell_layer = \
Image.new(img.mode, img.size, color=color)
mask = Image.new("L", img.size)
mask_dc = ImageDraw.Draw(mask)
half_t = thickness / 2
for (rdelta, fill) in ((-half_t, 255), (half_t, 0)):
mask_dc.ellipse(
[
center_x - r + rdelta, center_y - r + rdelta,
center_x + r - rdelta, center_y + r - rdelta
],
fill=fill
)
if (string):
gap_size = text_w + thickness * 2
half_gap_size = gap_size / 2
mask_dc.rectangle(
[
center_x - half_gap_size, center_y + r - half_gap_size,
center_x + half_gap_size, center_y + r + half_gap_size
],
fill=0
)
img = Image.composite(ell_layer, img, mask)
# Apply text
if (string):
dc = ImageDraw.Draw(img)
text_x = center_x - text_w / 2
text_y = center_y + r - text_h / 2
dc.text((text_x, text_y), string, font=font, fill=color)
return img
# Auxiliary routines
def prerr(*args, **kwargs):
print(*args, **kwargs, file=sys.stderr)
def get_hsl_gradient_point(val, min_val, max_val, start_rgb, end_rgb):
""" Return an RGB position on a HSL gradient. RGB tuples have values in
the range [0 .. 255].
"""
if (val < min_val):
val = min_val
elif (val > max_val):
val = max_val
start = colorsys.rgb_to_hls( \
start_rgb[0] / 255, start_rgb[1] / 255, start_rgb[2] / 255)
end = colorsys.rgb_to_hls( \
end_rgb[0] / 255, end_rgb[1] / 255, end_rgb[2] / 255)
hls = [0, 0, 0]
for i in range(3):
ch_delta = end[i] - start[i]
val_range = max_val - min_val
normal_val = val - min_val
normal_max_val = max_val - min_val
hls[i] = start[i] + ch_delta * (normal_val / normal_max_val)
rgb = colorsys.hls_to_rgb(*hls)
return (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
def print_date(img, date, font_size=40, padding=10):
""" Print the date into the upper left corner of image. """
dc = ImageDraw.Draw(img)
font = ImageFont.truetype(FONT_FN, font_size)
date_str = date.strftime("%b\n%d")
dc.text((padding, padding), date_str, font=font, fill=(20, 20, 20))
def is_summer(date):
loc = astral.Location()
loc.longitude = 0
loc.latitude = 60
north_rise = loc.sunrise(date=date)
loc.latitude = 30
south_rise = loc.sunrise(date=date)
return north_rise < south_rise
# Debugging routines
def debug_hsl_gradient(img, *extra):
min_val = extra[0]
max_val = extra[1]
dim = 50
dc = ImageDraw.Draw(img)
for i in range(min_val, max_val + 1):
color = get_hsl_gradient_point(i, *extra)
pos = i - min_val
dc.rectangle([0 + pos*dim, 0, 0 + pos*dim + dim, dim], fill=color)
def debug_latitudes(img):
start = None
for i in range(MIN_LATITUDE, MIN_LATITUDE+5):
if (i % 5 == 0):
start = i
break
for lat in range(start, MAX_LATITUDE, 5):
img = outline_latitude(img, lat, str(lat))
return img
# Entry point
def main():
if (ANIMATE):
animate()
else:
date = datetime.date(2016, 6, 20)
img = Image.open(IMAGE_FN)
if (DEBUG):
img = debug_latitudes(img)
img = debug_hsl_gradient(img)
else:
img = apply_isolines_to_image(img, date)
img.save("out.png")
main()