用 OpenCV + numpy 实现一个星空穿梭动效
(下一站,猎户座, Part 4)
前言
前年买了台 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)
几件事:
z3是当前帧的深度,随时间按speed * dir前进或后退。超出[0.05, 2]就跳过——太近会溢出屏幕,太远干脆看不见。- 坐标先平移到屏幕中心为原点,做旋转和缩放,再平移回来。这是个典型的"绕中心变换"套路,手写比调
cv2.getRotationMatrix2D更直接。 - 旋转角
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} $$
上面代码里 c 和 f 的推导就是这个平移补偿项——因为旋转默认绕原点,如果直接乘回去,图像会偏掉;把中心点 (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:
- 用现成的 star2.png 贴图,而不是代码实时生成星点。星点要好看,得带光晕、衍射芒——如果每一帧都用代码现算高斯光晕叠十字芒,一千个粒子循环下来一帧能跑好几秒。预先画一张 PNG 存好,运行时只做 resize + alpha blend,本质上是 O(贴图像素数) 的操作,快得多。这是典型的"空间换时间",而且素材还能替换,想要什么风格的星点换张图就行。
- 输出分辨率固定到 1080p。原始手机拍的照片动辄两千万像素,直接拿来逐帧 resize + alpha blend,一帧要算几百毫秒。先缩到 1080p,十几秒视频几秒钟就出完了。
- 粒子与背景的参数最好解耦。虽然合起来看都是"镜头向前推进",但粒子速度比背景快一截才有空间纵深感——背景是远景慢慢放大,粒子是近处呼啸而过。
- 星点检测的阈值不要太低。
threshold=20是个平衡——低了噪点会被当成星点导致满屏雪花,高了又会丢掉暗星。