lyyyuna 的小花园

动静中之动, by

RSS

用 OpenCV + numpy 实现一个星空穿梭动效

发表于 2026-04
NebulaDance 背后是怎么把一张静态的猎户座大星云照片,变成几千颗会飞的星点

前言

前年买了台 DWARF3 智能望远镜,第一次拍回来的就是猎户座大星云(M42)。第一次亲眼看到那团橘红色的云气时,其实挺激动的——虽然跟哈勃的照片没法比,但毕竟是自己拍的。

照片拍完,用原图做过壁纸,用抠过背景的版本发过朋友圈。但一张静态图片总觉得少了点什么——星空本该是流动的。之前开源过一个小工具 NebulaDance,给星空照片加上"穿梭星河"那种动态效果,之前写过一篇,但那篇只是发布公告,没讲代码。本文补一下,顺便也算自己复盘一遍。

效果想象一下大家熟悉的 Windows 屏保:镜头向前推进,星点由远及近飞过屏幕。区别在于,星点不是随机生成的,而是从照片里真实存在的星点抠出来的——镜头推近之后,应该能"对得上"原图。

整个项目核心就一个 render.py,不到 300 行 Python,依赖只有 OpenCV 和 numpy。下面拆开看。

第一步:找星点

对星空照片来说,找星点是最容易的图像处理问题——背景几乎是纯黑的,星点是孤立的亮斑。标准做法是找局部极大值:

def _find_local_maxima(self, min_distance, threshold):
    gray_image = cv2.cvtColor(self._resized_image, cv2.COLOR_BGR2GRAY)
    kernel = np.ones((min_distance * 2 + 1, min_distance * 2 + 1), dtype=np.uint8)
    dilated = cv2.dilate(gray_image, kernel)
    maxima = (gray_image == dilated) & (gray_image >= threshold)
    coords = np.column_stack(np.where(maxima))
    return coords

思路有点绕,看个例子便一目了然。cv2.dilate 是形态学膨胀——每个像素取邻域内的最大值。所以膨胀后,原图中每个局部极大值点的像素值不变,而非极大值点会被邻居的更大值覆盖掉。这样 gray_image == dilated 就筛出了局部极大值的位置。threshold 再把暗处的噪点过滤掉。

min_distance=5 意味着以该点为中心的 11×11 区域内它必须是最亮的,这样不会把一颗大星拆成好几个粒子。默认 threshold=20 经验值,亮度比这个还暗的就不值得做粒子了。

找到的坐标全部存起来、打乱顺序,后面按需取前 N 个当粒子。

self._all_coords = self._find_local_maxima(min_distance=5, threshold=20)
np.random.shuffle(self._all_coords)

第二步:给每颗星点一个"远近"

静态图里的星点只有 (x, y),要让它们动起来,需要伪造第三维 z

self._particles = np.array([
    [x, y, np.random.uniform(0.1, 1.0), np.random.randint(1, self._particle_size)]
    for y, x in self._coords
])

每颗粒子是 (x, y, z, size)z[0.1, 1.0] 间随机——相当于给每颗星一个随机的初始深度,这样后面穿梭时不会所有星点同步移动,而是有"远景"和"近景"之分,视觉上才有层次。

size 随机从 [1, particle_size) 之间取。星点有大有小,越近的(z 越小)在屏幕上显得越大——这个后面投影的时候会自动体现出来。

第三步:把 z 投影回屏幕

透视投影最朴素的模型:屏幕坐标 = 实际坐标 / z。z 越大(越远),缩放因子越小,粒子往屏幕中心收缩;z 越小(越近),粒子从屏幕中心往外发散、变大。写成式子就是:

$$ \begin{pmatrix} x_{2d} \cr y_{2d} \end{pmatrix} = \begin{pmatrix} c_x \cr c_y \end{pmatrix} + \frac{1}{z} \begin{pmatrix} \cos\theta & -\sin\theta \cr \sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} x - c_x \cr y - c_y \end{pmatrix} $$

(cx, cy) 是屏幕中心,θ 是粒子整体旋转角。对照代码就是这一段:

for x, y, z, size in self._particles:
    z3 = z + t * speed * self._particle_dir * -1
    if not 0.05 < z3 < 2:
        continue

    scale_p = 1 / z3
    x0, y0 = x - self._out_w / 2, y - self._out_h / 2
    x_rot = x0 * cos_theta - y0 * sin_theta
    y_rot = x0 * sin_theta + y0 * cos_theta
    x2d = int(self._out_w / 2 + x_rot * scale_p)
    y2d = int(self._out_h / 2 + y_rot * scale_p)

几件事:

  1. z3 是当前帧的深度,随时间按 speed * dir 前进或后退。超出 [0.05, 2] 就跳过——太近会溢出屏幕,太远干脆看不见。
  2. 坐标先平移到屏幕中心为原点,做旋转和缩放,再平移回来。这是个典型的"绕中心变换"套路,手写比调 cv2.getRotationMatrix2D 更直接。
  3. 旋转角 p_angle 是每帧给所有粒子一个整体的小角度——这样画面上除了穿梭感,还多一层轻微旋转,像在太空船里略微打了个斜滚转。

然后 cos_theta, sin_theta 在循环外预计算:

p_angle = np.deg2rad(self._particle_rotate * t / self._duration)
cos_theta, sin_theta = np.cos(p_angle), np.sin(p_angle)

一千个粒子的循环,三角函数每帧只算一次,比在循环里算节省不少。这个项目里粒子数不大,不算性能瓶颈,但写 numpy 代码久了,这种顺手的优化还是留着。

第四步:背景也得动

只有粒子飞,背景静止不动,看着像硬贴上去的。所以背景图同步做仿射变换,让整张图也跟着缓慢缩放和旋转:

def _get_affine_matrix(self, scale, angle_deg):
    cx, cy = self._out_w / 2, self._out_h / 2
    theta = np.deg2rad(angle_deg)
    cos_theta = np.cos(theta)
    sin_theta = np.sin(theta)

    a = scale * cos_theta
    b = scale * -sin_theta
    c = (1 - a) * cx - b * cy
    d = scale * sin_theta
    e = scale * cos_theta
    f = (1 - e) * cy - d * cx

    return np.array([[a, b, c], [d, e, f]])

这里没有直接调 cv2.getRotationMatrix2D,而是手写了仿射矩阵。原因是公式很简单——绕 (cx, cy) 旋转 θ 并缩放 s,对应的 2×3 仿射矩阵是:

$$ M = \begin{bmatrix} s \cos\theta & -s \sin\theta & (1 - s \cos\theta) c_x + s \sin\theta \cdot c_y \cr s \sin\theta & s \cos\theta & (1 - s \cos\theta) c_y - s \sin\theta \cdot c_x \end{bmatrix} $$

上面代码里 cf 的推导就是这个平移补偿项——因为旋转默认绕原点,如果直接乘回去,图像会偏掉;把中心点 (cx, cy) 先减掉、变换完再加回来,展开合并之后就是这个形式。

背景的缩放因子跟粒子的 z 类似:

scale = 1 + self._z_init * 0.1 + self._speed * t * 0.002 * self._z_dir

z_init 给一个起始缩放值(比如默认 3,即初始放大 1.3 倍),speed * dir 控制每一帧的增量。粒子与背景用的是两套参数,我故意拆开的——有时候想让背景慢慢放大而粒子快速扑面而来,两者解耦才灵活。

第五步:把粒子贴到背景上

粒子不是画一个纯白的点,而是用一张预先准备好的 star2.png——一个带光晕的星点贴图(32×32 左右,带 alpha 通道)。贴上去就是标准的 alpha 混合:

$$ C_{out} = \alpha \cdot C_{particle} + (1 - \alpha) \cdot C_{bg} $$

对应到代码就是一行:

alpha = resized_particle[:, :, 3:] / 255.0
frame[...] = alpha * resized_particle[:, :, :3] + (1 - alpha) * roi

size 决定了粒子贴图被 resize 到多大。靠近屏幕中心的粒子(未来会"穿过"摄像机的那些)z3 逐渐接近 0,scale_p = 1/z3 变得很大——配合上原本的 size,贴图就被拉大,给人"扑面而来"的感觉。

一点细节:为了避免贴图越过屏幕边缘时越界,上下左右都做了 max(0, ...)min(out_w/h, ...) 的裁剪。这也意味着一颗粒子临近屏幕边缘时会被裁成半个——这倒不是 bug,反而像星点"飞出画面",很自然。

第六步:淡入淡出

最后一道工序,视频开头和结尾做一个淡入淡出,不然硬切进/切出太突兀:

fade_frames = int(self._fade * self._fps)
if frame_idx < fade_frames:
    alpha = frame_idx / fade_frames
elif frame_idx > self._total_frames - fade_frames:
    alpha = (self._total_frames - frame_idx) / fade_frames
else:
    alpha = 1.0
frame = (frame * alpha).astype(np.uint8)

整帧乘一个从 0 到 1 再回到 0 的系数,写成分段函数:

$$ \alpha(i) = \begin{cases} i / f & 0 \leq i < f \cr 1 & f \leq i \leq N - f \cr (N - i) / f & N - f < i \leq N \end{cases} $$

其中 f = fade * fps 是淡入淡出覆盖的帧数,N 是总帧数。默认 fade=1.0 秒,30 fps 下就是 30 帧的过渡,够用。

参数一览

整个 Render 的参数暴露给 QML 界面调节,算是这个工具的核心:

参数 默认值 作用
z_init 3 背景初始缩放,越大起点越"近"
z_dir -1 背景移动方向(-1 放大,+1 缩小)
speed 1 背景运动速度
rotate -5 背景整体旋转角度
particle_num 1000 粒子数量,从检测出的星点里取前 N 个
particle_size 16 粒子最大像素尺寸
particle_speed 3 粒子 z 方向运动速度
particle_dir -1 粒子运动方向
particle_rotate -4 粒子整体旋转角度
duration 15 视频总时长(秒)
fps 30 帧率
fade 1.0 淡入淡出时长(秒)

小结

几条自己踩过的 Tips:

  1. 用现成的 star2.png 贴图,而不是代码实时生成星点。星点要好看,得带光晕、衍射芒——如果每一帧都用代码现算高斯光晕叠十字芒,一千个粒子循环下来一帧能跑好几秒。预先画一张 PNG 存好,运行时只做 resize + alpha blend,本质上是 O(贴图像素数) 的操作,快得多。这是典型的"空间换时间",而且素材还能替换,想要什么风格的星点换张图就行。
  2. 输出分辨率固定到 1080p。原始手机拍的照片动辄两千万像素,直接拿来逐帧 resize + alpha blend,一帧要算几百毫秒。先缩到 1080p,十几秒视频几秒钟就出完了。
  3. 粒子与背景的参数最好解耦。虽然合起来看都是"镜头向前推进",但粒子速度比背景快一截才有空间纵深感——背景是远景慢慢放大,粒子是近处呼啸而过。
  4. 星点检测的阈值不要太低threshold=20 是个平衡——低了噪点会被当成星点导致满屏雪花,高了又会丢掉暗星。
lyyyuna 沪ICP备2025110782号-1