-
Notifications
You must be signed in to change notification settings - Fork 0
/
moon.py
executable file
·644 lines (512 loc) · 20.4 KB
/
moon.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
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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
"""A module providing facilities to analyze the moon in an image.
Methods in this module predominantly deal with determining the relationship
between the phase of the moon and the apparent size of the moon in an all-sky
image. The phase of the moon is provided on a scale from 0.0 (a new moon) to
1.0 (a full moon). Methods are provided to find the position of the moon and
sun. The phase of the moon in an eclipse and outside of an eclipse are
separated into their own methods. There is also a method provided to plot this
data and the model linking the apparent size of the image to the moon phase.
"""
import os
import math
import warnings
import ephem
import numpy as np
from scipy import ndimage
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
from astropy.modeling import models, fitting
import coordinates
import image
# Sets up a pyephem object for the camera.
camera = ephem.Observer()
camera.lat = "31.959417"
camera.lon = "-111.598583"
camera.elevation = 2120
def eclipse_phase(d):
"""Calculate the proportion of the moon that is lit up during an eclipse.
Parameters
----------
d : float
The distance between the center of Earth"s shadow and the center of the
moon in kilometers.
Returns
-------
float
The phase of the moon, ranging between 0.0 and 1.0, where 0.0 is a new
moon and 1.0 is a full moon.
"""
# In kilometers.
r = 1737 # R_moon
R = 4500 #R_earth
# This makes addition work as addition and not concatenates
d = np.asarray(d)
d = np.abs(d) # Required as for after totality times d < 0
r2 = r * r
R2 = R * R
d2 = np.square(d)
# Part 1 of the shaded area equation
a = (d2 + r2 - R2)
b = d * 2 * r
p1 = r2 * np.arccos(a / b)
# Part 2 of the shaded area equation
a = (d2 + R2 - r2)
b = d * 2 * R
p2 = R2 * np.arccos(a / b)
# Part 3 of the shaded area equation
a1 = (r + R - d)
a2 = (d + r - R)
a3 = (d - r + R)
a4 = (d + r + R)
p3 = (0.5) * np.sqrt(a1 * a2 * a3 * a4)
# Add them together to get the shaded area
A = p1 + p2 - p3
# Get the shaded proportion by divding the shaded area by the total area
# Assumes r is the radius of the moon being shaded.
P = A / (np.pi * r2)
# P is the shaded area, so 1-P is the lit up area.
P = 1 - P
return P
# 1.0 = Full moon, 0.0 = New Moon
def moon_phase(img):
"""Calculate the proportion of the moon that is lit up for non-eclipse
nights.
Parameters
----------
img : image.AllSkyImage
The image.
Returns
-------
float
The phase of the moon, ranging between 0.0 and 1.0, where 0.0 is a new
moon and 1.0 is a full moon.
"""
# Sets the calculation date.
camera.date = img.formatdate
# Makes a moon object and calculates it for the observation location/time
moon = ephem.Moon()
moon.compute(camera)
return moon.moon_phase
def moon_size(img):
"""Calculate the area of the moon in pixels in a given image.
Parameters
----------
img : image.AllSkyImage
The image.
Returns
-------
int
The size of the moon in pixels.
Notes
-----
This method first converts the image to a black and white two-tone image
where pixels with greyscale values above or equal to 250 is set to
white and everything below is set to black. A binary closing is performed
on the image, which smooths over any small black pixel regions within the
moon. These black regions are created when the pixels are brighter than the
maximum of 255 for white pixels and overflow back to 0.
The white regions are labeled and their sizes are found using
ndimage.label. Then, the approximate position of the center of the moon
is found using find_moon.
If the pixel at the moon"s center is black (due to aforementioned pixel
value overflow), the nearest white region along the x axis is found and
the size of this region is returned.
If this pixel is white, the size of this white region is returned.
"""
thresh = 5
dat = np.where(img.data >= 255 - thresh, 1, 0)
# Runs a closing to smooth over local minimums (which are mainly caused by
# a rogue antenna). Then labels the connected white regions. Structure s
# Makes it so that regions connected diagonally are counted as 1 region.
s = [[1, 1, 1], [1, 1, 1], [1, 1, 1]]
dat = ndimage.morphology.binary_closing(dat, structure=s)
labeled, nums = ndimage.label(dat, structure=s)
# Want to find the size of each labeled region.
sizes = [0] * (nums + 1)
for row in labeled:
for val in row:
sizes[val] = sizes[val] + 1
# We want to exclude the background from the "biggest region" calculation.
# It"s quicker to just set the background (0) region to 0 than to check
# every single value above before adding it to the array.
sizes[0] = 0
# Following code calculates d, the distance between the center of
# Earth"s shadow and the center of the moon. Basically just d = v*t.
# Use pyephem to find the labeled region that the moon is in.
posx, posy, _ = find_moon(img)
posx = math.floor(posx)
posy = math.floor(posy)
reg = labeled[posy, posx]
# Very large and bright moons have a dark center (region 0) but I want the
# region of the moon.
while reg == 0 and posx < 511:
posx = posx + 1
reg = labeled[posy, posx]
biggest = sizes[reg]
return biggest
def find_moon(img):
"""Find the (x, y, alt) coordinate of the moon"s center in a given image.
Parameters
----------
img : image.AllSkyImage
The image.
Returns
-------
x : float
The x coordinate of the moon"s center.
y : float
The y coordinate of the moon"s center.
alt : float
The altitude angle of the moon"s center.
Notes
-----
The x and y coordinates are corrected for irregularities in the lens using
coordinates.galactic_conv.
"""
# Sets the date of calculation.
camera.date = img.formatdate
# Calculates the moon position.
moon = ephem.Moon()
moon.compute(camera)
# Conversion to x,y positions on the image.
alt = np.degrees(moon.alt)
az = np.degrees(moon.az)
x, y = coordinates.altaz_to_xy(alt, az)
x, y = coordinates.galactic_conv(x, y, az)
return (x, y, alt)
def find_sun(img):
"""Find the (alt, az) coordinate of the sun"s center in a given image.
Parameters
----------
img : image.AllSkyImage
The image.
Returns
-------
alt : float
The altitude coordinate of the sun"s center.
az : float
The azimuth coordinate of the sun"s center.
"""
# Sets the date of calculation.
camera.date = img.formatdate
# Calculates the sun position.
sun = ephem.Sun()
sun.compute(camera)
# Conversion to x,y positions on the image.
alt = np.degrees(sun.alt)
az = np.degrees(sun.az)
return (alt, az)
# Fits a Moffat fit to the moon and returns the estimated radius of the moon.
# Radius of the moon is the FWHM of the fitting function.
def fit_moon(img, x, y):
"""Fit a Moffat function to the moon in a given image.
Parameters
---------
img : image.AllSkyImage
The image.
x : float
The x coordinate of the moon"s center.
y : float
The y coordinate of the moon"s center.
Returns
-------
float
The Full Width at Half Maximum of the Moffat function.
"""
# This block of code runs straight vertical from the center of the moon
# It gives a predicted rough radius of the moon, it starts counting at the
# first white pixel it encounters (the center may be black)
# and stops at the last white pixel. White here defined as > 250 greyscale.
yfloor = math.floor(y)
count = False
size = 0
xfloor = math.floor(x)
start = xfloor
# The only reason we have this if block is to ensure we don"t run for
# moon radii greater than 35 in this case.
for i in range(0, 35):
start += 1
# Breaks if it reaches the edge of the image.
if start == img.data.shape[1]:
break
if not count and img.data[yfloor, start] >= 250:
count = True
elif count and img.data[yfloor, start] >= 250:
size += 1
elif count and img.data[yfloor, start] < 250:
break
# Add some buffer pixels in case the center is black and the edges of the
# moon are fuzzed and then convert radius to diameter.
size = (size + 10) * 2
# Makes sure the lower/upper slices don"t out of bounds error.
lowerx = xfloor - size if (xfloor - size > 0) else 0
lowery = yfloor - size if (yfloor - size > 0) else 0
upperx = xfloor + size if (xfloor + size < 511) else 511
uppery = yfloor + size if (yfloor + size < 511) else 511
# Size of the moon enclosing square.
deltax = (upperx - lowerx)
deltay = (uppery - lowery)
# Creates two arrays, with the array values being the x or y coordinate of
# that location in the array.
y, x = np.mgrid[0:deltay, 0:deltax]
# Slices out the moon square and finds center coords.
z = img.data[lowery:uppery, lowerx:upperx]
midy = deltay / 2
midx = deltax / 2
# Moffat fit, centered in square, stdev of 20 as a start.
stddev = 20
model_init = models.Moffat2D(amplitude=200, x_0=midx, y_0=midy,
gamma=stddev)
fit = fitting.LevMarLSQFitter()
with warnings.catch_warnings():
# Ignore model linearity warning from the fitter
warnings.simplefilter("ignore")
model = fit(model_init, x, y, z)
# /2 is average FWHM but FWHM = diameter, so divide by two again.
#fwhm = (model.x_fwhm + model.y_fwhm) / 4
fwhm = model.fwhm / 2
return fwhm
# Generates the size vs illuminated fraction for the two eclipse nights.
def generate_eclipse_data(regen=False):
"""Generate moon phase data for eclipses.
Parameters
----------
regen : bool, optional
If True, regen from scratch. Otherwise, load it from a file.
Defaults to False.
Returns
-------
truevis : list of lists
A list containing one or more lists where each list contains values
corresponding to an eclipse. Each value is the phase of the moon
ranging from 0.0 to 1.0, in an image taken during that night.
0.0 corresponds to a new moon, while 1.0 corresponds to a full moon.
The list is ordered in chronological order; that is, the first value
within the list is calculated from an image that was taken at the
beginning of the eclipse, and the last value is taken from the end of
the eclipse. Currently, this is hardcoded such that the first list
represents the eclipse on 2018/01/31, and the second list represents
the eclipse on 2015/04/04.
imvis : list of lists
A list containing one or more lists where each list contains values
corresponding to an eclipse. Each value is the area of the moon,
in pixels, in an image taken during that night. The list is ordered in
chronological order; that is, the first value within the list is
calculated from an image that was taken at the beginning of the eclipse,
and the last value is taken from the end of the eclipse.
Currently, this is hardcoded such that the first list represents
the eclipse on 2018/01/31, and the second list represents the eclipse
on 2015/04/04.
"""
dates = ["20180131", "20150404"]
# Function within a function to avoid code duplication.
def data(date):
# Necessary lists
distances = []
imvis = []
truevis = []
# Check to see if the data has been generated already.
# If it has then read it from the file.
save = os.path.join(os.path.dirname(__file__), *["data", "eclipse-" + date + ".txt"])
if os.path.isfile(save) and not regen:
f = open(save)
for line in f:
line = line.rstrip().split(",")
truevis.append(float(line[0]))
imvis.append(float(line[1]))
f.close()
return (truevis, imvis)
# If we're regenerating the data we do it here.
directory = os.path.join(os.path.dirname(__file__), *["Images", "Original", "KPNO", date])
images = sorted(os.listdir(directory))
# I need a better way to check this.
if ".DS_Store" in images:
images.remove(".DS_Store")
# Finds the size of the moon in each image.
for name in images:
print(name)
print(date)
print(directory)
img = image.load_image(name, date, "KPNO")
# This basically hacks us to use the center of the earth as our
# observation point.
camera.elevation = - ephem.earth_radius
camera.date = img.formatdate
# Calculates the sun and moon positions.
moon = ephem.Moon()
sun = ephem.Sun()
moon.compute(camera)
sun.compute(camera)
# Finds the angular separation between the sun and the moon.
sep = ephem.separation((sun.az, sun.alt), (moon.az, moon.alt))
# Radius of moon orbit to convert angular separation -> distance
R = 385000
# For angles this small theta ~ sin(theta), so I dropped the sine
# to save computation time.
# Angle between moon and earth"s shadow + angle between moon and sun
# should ad d to pi, i.e. the earth"s shadow is across from the sun.
d = R * (np.pi - sep)
size = moon_size(img)
imvis.append(size)
distances.append(d)
print("Processed: " + date + "/" + name)
# Calculates the proportion of visible moon for the given distance
# between the centers.
truevis = eclipse_phase(distances)
imvis = np.asarray(imvis)
# If the moon is greater than 40,000 pixels then I know that the moon
# has merged with the light that comes from the sun and washes out the
# horizon.
imvis = np.where(imvis < 80000, imvis, float("NaN"))
f = open(save, "w")
# Writes the data to a file so we can read it later for speed.
for i in range(0, len(truevis)):
f.write(str(truevis[i]) + "," + str(imvis[i]) + "\n")
f.close()
return (truevis, imvis)
trues = []
ims = []
for date in dates:
true, im = data(date)
trues.append(true)
ims.append(im)
return (trues, ims)
def moon_circle(frac):
"""Calculate the estimated pixel radius of the moon based on the
fraction of the moon that is illuminated.
Parameters
----------
frac : float
The proportion of the moon that is illuminated by sunlight.
Returns
-------
float
The estimated radius of the moon.
Notes
-----
The model used in this method to convert between the fraction of the moon
that is illuminated to the estimated pixel area was found by plotting the
moon pixel area versus the moon phase and picking representative points
to model the relation. The
model is designed to always overestimate the size of the moon. The model
is defined by interpolating from the following table of representative
points:
========================= ==================
Moon fraction illuminated Moon area (pixels)
------------------------- ------------------
0 650
0.345 4000
0.71 10500
0.88 18000
0.97 30000
1.0 35000
========================= ==================
"""
illuminated = [0, 0.345, 0.71, 0.88, 0.97, 1.0]
size = [650, 4000, 10500, 18000, 30000, 35000]
A = np.interp(frac, illuminated, size)
return np.sqrt(A/np.pi)
def moon_mask(img):
"""Generate a masking array that covers the moon in a given image.
Parameters
---------
img : image.AllSkyImage
The image.
Returns
-------
numpy.ndarray
An array where pixels inside the moon are marked with False and those
outside the moon are marked with True.
"""
# Get the fraction visible for interpolation and find the
# location of the moon.
vis = moon_phase(img)
x, y, _ = find_moon(img)
# Creates the circle patch we use.
r = moon_circle(vis)
circ = Circle((x, y), r, fill=False)
# The following code converts the patch to a 512x512 mask array, with True
# for values outside the circle and False for those inside.
# This is the same syntax as np.ma.make_mask returns.
# This section of code generates an 262144x2 array of the
# 512x512 pixel locations. 262144 = 512^2
points = np.zeros((512**2, 2))
index = 0
for i in range(0, 512):
for j in range(0, 512):
# These are backwards as expected due to how reshape works later.
# Points is in x,y format, but reshape reshapes such that
# it needs to be in y,x format.
points[index, 0] = j
points[index, 1] = i
index += 1
# Checks all the points are inside the circle, then reshapes it to the
# 512x512 size.
mask = circ.contains_points(points)
mask = mask.reshape(512, 512)
return mask
def generate_plots():
"""Generate a plot of illuminated fraction versus apparent moon size.
Notes
-----
The eclipse dataset is loaded using generate_eclipse_data and then plotted.
The file images.txt contains the illuminated fraction of the moon
and the pixel area of the moon in that image. This data is plotted on top
of the eclipse data. The illuminated fraction of the moon is found using
moon_phase, and the size of the moon in the image is found using moon_size.
On top of this data the theoretical moon size model used in moon_circle is
plotted. Once all of these are plotted, two versions of the plot are saved,
one with a standard y and x axis, and one with a logarithmic y axis.
These plots are saved directly to Images/ under the names "moon-size.png"
and "moon-size-log.png."
"""
# Loads the eclipse data
vis, found = generate_eclipse_data()
print("Eclipse data loaded!")
# Eclipse normalization code.
#found[0] = np.asarray(found[0]) / np.nanmax(found[0])
#found[1] = np.asarray(found[1]) / np.nanmax(found[1])
# Plots the two eclipses, the first in blue (default), the second in green
plt.scatter(vis[0], found[0], label="2018/01/31 Eclipse", s=7)
#plt.scatter(vis[1], found[1], label="2015/04/04 Eclipse", s=7, c="g")
plt.ylabel("Approx Moon Size (pixels)")
plt.xlabel("Illuminated Fraction")
# Vis is the portion of the moon illuminated by the sun that night
# Found is the approximate size of the moon in the image
vis = []
found = []
loc = os.path.join(os.path.dirname(__file__), *["data", "images.txt"])
with open(loc, "r") as f:
for line in f:
line = line.rstrip()
info = line.split(",")
img = image.load_image(info[1], info[0], "KPNO")
vis.append(moon_phase(img))
found.append((moon_size(img)))
print("Processed: " + info[0] + "/" + info[1] + ".png")
# Removes out any moons that appear too large in the images to be
# considered valid.
found = np.asarray(found)
found = np.where(found < 40000, found, float("NaN"))
# Normalizes the non eclipse data.
#found1 = found / np.nanmax(found)
# Adds the noneclipse data to the plot.
plt.scatter(vis, found, label="Regular", s=7)
# This plots the estimated model of moon size on top of the graph.
vis2 = [0, 0.345, 0.71, 0.88, 0.97, 1.0]
found2 = [650, 4000, 10500, 18000, 30000, 35000]
plt.plot(vis2, found2, label="Model", c="r")
# Interpolation estimate for the moon size in the image based on the
# illuminated fractions.
found3 = np.interp(vis, vis2, found2)
plt.scatter(vis, found3, label="Interpolated", s=7)
plt.legend()
# Saves the figure, and then saves the same figure with a log scale.
plt.savefig("Images/moon-size.png", dpi=256)
ax = plt.gca()
ax.set_yscale("log")
plt.savefig("Images/moon-size-log.png", dpi=256)
plt.close()
if __name__ == "__main__":
# 20160326/r_ut071020s70020
generate_plots()