用 Python 绘制盈亏月相:从直觉误区到闭合多边形方案
初衷很简单:给定任意日期,画出一轮和现实一致的月亮——盈亏大小、月牙朝向都贴合肉眼所见。
最开始我本能想到用两个圆裁切,一路踩坑,最后找到了一套简洁自洽的实现思路。整个过程整理于此,希望能帮后来者少走弯路。
引子
我想用 Python 画月亮。不是随手画一个圆形贴纸,而是由日期驱动的真实月相:什么时候是细月牙、什么时候是弦月、亮面朝左还是朝右,都和我们抬头看见的一致。
一开始以为这是个小任务。从「两个圆互相裁剪」这个很自然的想法起步,一路碰壁,最后收敛到一套足够优雅的算法。我把完整思考链路记录下来。
一、先厘清核心:月相的成因
动笔写代码之前,得先分清一件极易混淆的事:
日常的阴晴圆缺,不是地球影子挡住月亮(那是月食,一年仅有少数几次)。
月球本身不发光,只有朝向太阳的半球被照亮。我们站在地球上观察,看到的亮面占比随相对角度持续变化,这就是月相。
我们用相位角描述太阳-月球-地球三者的几何关系:
| 相位角 | 月相 | 简要说明 |
|---|---|---|
| 0° | 新月(朔) | 几乎不可见,日月同升同落 |
| 0°–90° | 蛾眉月 | 右侧细月牙,傍晚见于西天 |
| 90° | 上弦月 | 右半侧明亮 |
| 90°–180° | 盈凸月 | 持续向满月靠近 |
| 180° | 满月(望) | 整个月面全亮 |
| 180°–270° | 亏凸月 | 亮面逐步缩小 |
| 270° | 下弦月 | 左半侧明亮 |
| 270°–360° | 残月 | 左侧细月牙,凌晨见于东天 |
新月到下一次新月的平均周期为 29.530588853 天,称为朔望月。
选定一个已知新月的儒略日作为基准,用当前儒略日对朔望月取模,即可得到月龄,换算相位角。下面是最简版本的计算公式:
SYNODIC_MONTH = 29.530588853 |
还有一个实用公式,用来计算亮面占整个月轮的比例:
照亮占比 = (1 - cos(相位角)) / 2 |
在 0°、180° 分别得到 0 和 100%;90°、270° 刚好 50%,和上弦、下弦月对半亮的观感吻合。
二、第一思路:双圆裁切,看似可行,实则走不通
拿到相位角之后,最先冒出来的想法非常直观:
满月是一个整圆;想要月牙,就叠加另一个圆形盖住一部分,剩下露出的区域就是月相。弦月看上去,本来就像是两段圆弧围出来的形状。
但真正动手才发现这个方案有两处硬伤:
- 形状本身不对:双圆裁切得到的明暗分界线是圆弧;而真实月面的明暗交界线(晨昏线/terminator)曲率是连续变化的,90°弦月时会退化成一条直线。无论怎么调整两个圆的大小位置,都无法复刻这条边界。
- 绘图原语受限:常规绘图库的圆弧接口只能绘制固定片段,很难精准取出任意一段弧,再和另一段弧线无缝拼接成可填充的闭合图形。
这个方案就此搁置。
三、修正认知:明暗分界线,是一个椭圆的半弧
跳出「两个圆」的思维定式,从三维几何重新理解:
月球是球体,太阳光在球面上切出的明暗分界是球面上的大圆。这个大圆投影到我们的观测平面上,就是椭圆。 我们肉眼看到的明暗界线,本质上是这个椭圆的一半,椭圆的扁率随着相位角持续改变。
设月球视半径为 R、相位角为 θ,该椭圆的半横轴长度为:
半横轴 = R · cos θ |
代入几个极值验证,逻辑自洽:
θ = 0°(新月):cosθ=1,分界线和月轮外缘重合,几乎没有亮面;θ = 90°(上弦月):cosθ=0,椭圆坍缩为一条竖直线,正好对半照亮;θ = 180°(满月):cosθ=-1,分界线翻转到另一侧外缘,整轮全部亮起。
公式里的负号是关键:它代表明暗边界翻转到对面,对应月相由盈转亏。
到这里,亮区的几何定义已经清晰:外侧是月轮的一段圆弧,内侧是半段椭圆弧,两段弧线共同围成亮区。
但新问题又来了:两段弧线在上下极点处相切,直接拼接填充极易出现细微缝隙;同时又回到老问题——绘图工具很难直接拼接两段不同类型的弧线。
四、放弃弧线拼接:采样点位,生成一条闭合多边形
既然用弧线拼接的路子处处受限,我换了策略:不再组合图形原语,直接构造一条连续的闭合轮廓。
分别对外侧月轮边缘、内侧明暗椭圆界线进行等角度采样,得到两组坐标点;再把界线的点位倒序拼接,首尾相连,形成一条不自交的闭合路径。直接填充这个多边形,不需要依赖弧线接口,也从根源避免拼接缝隙。
在参数 t 对应的高度位置,外缘和明暗界线横坐标:
外缘(月轮边缘 limb): x = ±R·sin t |
正负号由相位角判定:θ < 180°,亮区在右侧,取正;反之亮区在左侧,取负。
采样、拼合路径的核心函数:
def _lit_polygon(angle, center, radius): |
轮廓线条要放在填充之后绘制,防止轮廓线覆盖、切断月牙两极的收口。
五、图像干净度的关键:先超采样、变换,最后一次性二值化
我想要干净锐利、适合用作图标、素材的黑白月相图。二值图最容易踩的坑是操作顺序颠倒:先压缩成黑白,再做旋转,弧线会出现严重锯齿,细轮廓甚至断裂。
正确的流水线顺序固定为四步,顺序不能调换:
- 在 4 倍超采样的高画布上绘制多边形,保留灰度过渡余量;
- 在高分辨率画布上执行旋转(BICUBIC 插值);
- 降采样到目标尺寸(LANCZOS,保证边缘平滑);
- 最后只做一次二值化:亮度≥128置白,其余透明。
4倍超采样是性价比很高的选择,倍率再高,画质提升有限,但运算成本成倍上涨。核心原则:所有几何变换,都要在灰度高分辨率阶段完成,二值化放在最后一步。
六、朝向:月相倾角和观测纬度有关,南北半球观感翻转
形状算法搞定之后,还有一个很容易忽略的变量:月亮的倾斜角度。
同一个时刻的月亮,在赤道、我国北方、南半球悉尼看到的朝向并不一样:
- 观测纬度决定月面整体的倾斜角度;
- 南半球的观感不是轻微偏转,而是整体上下翻转。
简易旋转计算:
rotation = -lat |
再次强调:旋转操作必须放在高分辨率超采样阶段,不能在已经二值化的低分辨率图上旋转,否则锯齿、断线问题会复现。
七、精度取舍:平均朔望月够用吗?什么时候要接入星历?
到这一步,月亮的视觉效果已经足够逼真,但前面的相位角,还在用线性的平均朔望月公式计算。
这套简化模型误差大约半天。对于画图本身完全无感:在一百多像素的图上,半天对应的相位误差只有 2°左右,肉眼分辨不出差别。
促使我放弃简化公式的不是图像,而是附加信息:我还需要在图上标注「下次满月:xx点xx分」这类时间。
实测对比:真实满月 UTC 时刻为 04:12,均值公式算出是前一日 23:45,偏差约4.4小时;新月的误差甚至能接近11小时。弧线看不出差别,但给到用户阅读的时间标签,差几个小时就是硬错误。
于是引入 JPL DE421 星历:直接读取太阳、月球的真实位置,用黄经差计算精确相位角;新月、满月时刻用二分法求解,精度收敛到分钟级别,误差从小时级压缩到十几秒。
代价是引入十几MB的星历文件和天文计算依赖,所以代码做了降级兜底:星历缺失、依赖不可用时,自动回退到朔望月平均公式,图像照常生成,只是时间标签回到有几小时误差的状态。
这里得到一个很有意思的结论:精度需求,不由图片分辨率决定,而是由输出信息的用途决定。画一轮月牙,2°的误差无关紧要;标注一个具体时刻,几小时偏差就无法接受。
八、小结
回看整个实现过程,真正的突破不是某个复杂公式,而是两次思路转换:
- 从「用多个基础图形拼接月相」,转为「直接采样轮廓点,构造一条闭合多边形」。弧线本身很难组合,采样点的路径方案绕开了绘图原语的限制;
- 从「先二值化再变换」,转为「全部几何变换前置,二值化放在最后一步」,锯齿、轮廓断裂这些表象问题,找到了底层原因。
最终画出来的月亮并不大,通常只有一百多像素,当然渲染不出环形山。但它能在对应的日期、对应观测地点,呈现出正确的盈亏形态与倾斜朝向。对一段 Python 写出来的小可视化来说,这就足够了。