在电脑前坐了一整夜,导出相机里的300张星空原片,我意识到一个残酷的事实:靠人眼识别银河里那些星星的亮度等级,再手动标注星座区域,这种活干一次是情怀,干三次就是刑罚。那晚之后,我开始写一个能自动定位并识别星点的模型,取名 StarNet。它不是云服务,也不是什么大厂产品,只是一个天文摄影爱好者用业余时间训练出来的轻量级卷积网络,能在普通手机上实时跑。如果你也拍星空、做巡天监控,或者单纯想找个有意思的目标检测入门项目,这篇实战记录应该能给你不少可以照抄的代码和思路。
1. 为什么取名 StarNet:一个天文摄影师的算力妥协
1.1 拍星空的人都有过的痛点
大多数天文摄影的后期流程里,第一步就是“星点对齐”和“星点标注”。我平时用的是拼接软件,但它只给了对齐矩阵,不给星星的亮度等级、颜色信息,更不会告诉你“哪颗是织女星”“天鹅座的亮星分布如何”。想要做这些分析,只能另找星表工具,一张张往里填参数。
后来我想,能不能训练一个模型,输入一张普通照片,输出每个星点的像素坐标、星等,甚至给出星座区域?这个需求听上去很直接,但真正做起来才发现有几个难点:星点往往只有几个像素大小;照片里同时有成百上千个星点,光污染区还有一些假的点状噪声;边缘设备算力有限,不能跑那些动不动几百MB的大模型。最关键的是,我需要一个能离线运行的版本,这样在野外没有信号时也能当场标注。
1.2 重模型还是轻模型:我的取舍逻辑
先说明一下,我一开始确实试过 YOLOv5。它对常规目标很好使,但一到星空场景就暴露问题:星星太小,anchor 的尺寸很难设置,而且YOLO的NMS后处理在密集小目标上经常误删相邻星点。后来我又试了 CenterNet 风格的点检测思路,发现热力图回归天生适合“点状目标”密集分布的场景——每颗星星可以看成一个高斯点,模型输出一张概率热图,取局部峰值就是星点中心。
于是 StarNet 的定位就确定了:一个以 CenterNet 为骨架的轻量级点检测器。名字取“Star”和“Network”的双关,既点明了“星星”,又暗示它强调速度。我给自己定了一个硬指标:在手机端跑 512×512 输入,单帧耗时不超过 80ms。这个目标逼着我放弃 ResNet 这类重型骨干,改成深度可分离卷积堆出的轻量塔结构。
2. 训练数据的获取与自动标注:比模型本身更花时间
2.1 星表数据源:Gaia DR3 和 HYG 数据库的使用方式
训练一个模型,数据是最难的部分。真实星空照片虽然多,但要给每颗星星框出坐标和亮度等级,人工标注成本高到离谱。好在天文学有公开星表,最常用的是 HYG 数据库,它合并了 Hipparcos、Yale Bright Star 和 Gliese 的数据,包含十二万颗恒星的赤经、赤纬、视星等和颜色信息。如果做科研级应用,我会推荐欧洲航天局的 Gaia DR3,它有十亿颗恒星的数据,但文件太大,对普通爱好者来说 HYG 已经完全够用。
我把 HYG 的 CSV 文件下载下来,只保留视星等小于 8 的恒星,因为超过这个亮度,在城市光污染下基本看不见,训练了反而容易让模型困惑。每条记录大致长这样:恒星的编号,赤经(小时),赤纬(度),视星等(越小越亮)。这些是天球坐标,而相机照片是像素坐标,所以需要一个投影转换步骤。
2.2 合成星图的生成:用程序模拟不同曝光、大气、光污染下的星空
直接拍真实照片再人工标注工作量太大,我采用的是“合成星图+真实照片混合训练”的策略。合成星图的好处是标签绝对精确——因为数据是我自己生成的,每颗星的像素坐标和星等都是一手信息,不存在人工标注误差。
生成流程并不复杂:根据相机焦距、传感器尺寸和拍摄位置,把天球坐标投影到像平面坐标。我用的是标准透视投影,公式大致是:
import math def project(ra, dec, lat, lon, time, focal_length, sensor_width, image_width_px, image_height_px): # 这里省略了完整的时角/赤纬转换计算 # 返回像素坐标 (x, y) x = (focal_length * math.cos(dec) * math.sin(ra - lst) + sensor_offset_x) y = (focal_length * (math.sin(dec) - math.sin(lat) * math.cos(dec) * math.cos(ra - lst)) + sensor_offset_y) return x / sensor_width * image_width_px, y / sensor_height_px * image_height_px得到坐标后,关键的一步是模拟点扩散函数(PSF)。真实照片里的星点不是单像素,而是被大气折射和镜头光学模糊成一个小光斑。我会用一个高斯斑来绘制每颗星,高斯半径由视星等和模拟的噪点水平决定。星等越亮,光斑峰值越高,半径也略大一点。
为了提升模型在真实场景的鲁棒性,我不会生成那种“纯净的黑色天空”,而是在背景上叠加多种干扰:水平渐变天光、随机散粒噪声、几条卫星轨迹、偶尔的云层薄雾。这一步可以理解为“域随机化”,意思是让模型看见各种乱七八糟的星空照片,训练出来就不会被真实的传感器噪声带偏。
2.3 标注格式:yolo格式的转换与数据增强
StarNet 的训练标签不需要画矩形框,而是用高斯热力图,所以标注文件只需要记录三个字段:目标星点的类别(我按视星等分成 0-6 共 7 类:0 表示最亮,6 表示最暗),中心点的 x、y 坐标,以及高斯半径 sigma。这个 sigma 不是拍脑袋定的,它跟星点的PSF半径直接相关,训练时再把 sigma 编码成热力图上的高斯核。
数据增强方面,我试过很多种常规操作,最终保留的是:整图随机旋转 90 度、随机水平/垂直翻转、随机调整到 0.8~1.2 倍缩放、随机裁取边缘区域。因为星空中方向性不明显,旋转增强不会引入语义错误。但我刻意没有加色彩抖动,因为星点的颜色信息至关重要——蓝巨星和白矮星的颜色差异,是后期分类的重要依据。
3. StarNet 网络结构:用50行PyTorch搭出检测头
3.1 骨干网络选择:MobileNetV3 还是自研 Stem
我最早直接用 MobileNetV3-Large 做骨干,然后在最后几层接检测头。跑下来精度不错,但速度达不到我要的 80ms。后来换成了 MobileNetV3-Small,速度够了,可最暗的那一批星点召回率掉得厉害。做了几次消融实验后,我干脆自己搭了一个 4-Stage 的轻量塔:每个 Stage 由两层深度可分离卷积加一层 SE 注意力组成,通道数分别取 16、32、64、96。这个结构很像 MobileNetV1 的加速版,但少了几层,参数量只有 MobileNetV3-Small 的 60% 左右。
为什么敢砍这么狠?因为星点检测的特征其实比较“简单”——单个星点就是孤立的亮点,不需要太深的语义信息。真正难的是密集小目标的分离,这要靠热力图的峰值竞争,而不是靠更深的网络。所以我把省下的算力留给后面的大分辨率特征图,保证 512×512 输入下的 128×128 特征层还有足够分辨率,能够分辨相距很近的两颗星。
3.2 检测头设计:基于 CenterNet 的热力学回归为什么更合适
StarNet 的检测头分三条分支:热力图分支输出一个单独的通道,尺寸是 H/4、W/4;偏移分支输出两通道,用于修正热力图上量化到整数像素的误差;宽度分支输出一个通道,预测每个星点的高斯半径,用来在后处理阶段区分相邻星点。
热力图分支的通道数是 1,而不是像 COCO 那样每个类别一个通道,因为我的类别最终要靠“星等”来区分,而星等可以被当作连续值,不适合做离散分类。所以我换了个思路:主热力图只管“这个位置有没有星”,星等则由另一个回归分支去预测。这相当于把检测和测光分开做,训练起来稳定很多。
import torch import torch.nn as nn class StarNetHead(nn.Module): def __init__(self, in_channels=64): super().__init__() self.heatmap = nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 1, 1), ) self.offset = nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 2, 1), ) self.magnitude = nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 1, 1), ) def forward(self, feature): return self.heatmap(feature), self.offset(feature), self.magnitude(feature)3.3 损失函数:Focal Loss + L1 Loss 的组合策略
热力图分支我用的是改进版 Focal Loss,这也是 CenterNet 的标配。它能让模型把注意力集中在那些难分的“背景区域”——比如恰好有一朵暗淡云气的地方。标准的惩罚因子是 α=2、β=4,我最终调成了 α=2、β=3,因为星空背景里假阳性的惩罚可以稍微放松一点,宁可多找几个假星,也不要漏掉真星。
偏移分支和星等回归分支都用 L1 Loss。注意星等回归最好先在数据预处理时除以 10 做归一化,把输出范围压到 0~1 左右,否则最初几个 epoch 的梯度会非常暴躁,损失直接变成 NaN。这个坑我踩过,后面细说。
总损失是这三项的加权和。权重我设置成 1.0、0.1、0.1,让热力图的监督占绝对主导。如果你也想复现,建议先单独看热力图的 mAP,等热力图稳定了,再加上后面的分支,会好训练得多。
4. 训练过程的三次“翻车”与解决方案
4.1 小目标大量漏检:多尺度训练与FPN的应急改造
第一次完整训练跑完 200 个 epoch,我在真实照片上一测,发现视星等暗于 5 的星点几乎全军覆没。排查了一下,问题出在热力图下采样到 128×128,一个只有 3 像素宽的暗淡星点,在下采样后离散成了不到 1 个像素的响应峰,模型根本学不出规律。
我的应急方案有三步:第一,把输入和标签的尺寸对齐,确保热力图下采样倍率是 2 的幂次;第二,在骨干网最后添加一个轻量 FPN,把 stride 8 和 stride 16 的特征融合起来一起送进检测头;第三,训练时数据加载器里随机把输入缩放到 384、448、512、576 四种尺寸,让网络学会跨尺度识别小目标。
这三步做完,暗弱星点的召回率从 32% 提到了 71%,效果非常明显。所以如果你做的是小目标检测,千万别迷信“高倍下采样 + 大感受野”,先保住分辨率再说。
4.2 训练集和实拍照片风格不一致:域随机化的威力
第二个大问题是合成图训练出来的模型,一到真实照片上就有“科幻感”:背景噪点被识别成一片星点,而真实星点反而变得模糊。说白了就是训练数据太干净了,模型只学会了“暗背景上的孤立高亮点”,没学过真实传感器的 CMOS 暗电流和热像素。
这里我做了三件蠢事,你们可以跳过:一开始想用图像增强库硬怼高斯噪声,但噪声类型和传感器不匹配;后来想收集一万张真实照片人工标注,工期太长直接放弃;最终靠的是域随机化——在合成阶段就按真实相机的暗电流分布生成噪声,而不是简单的高斯白噪声。
具体操作是,我在模拟背景光子数时引入了泊松噪声和少量热像素(随机的常亮点,略微比真实星星更圆),还对整张图做随机 Gamma 和对比度扰动。这样训练出来的模型,第一次上实拍图,星点召回率居然直接达到了 79%,几乎和合成测试集持平。
4.3 正负样本极度不均衡:Focal Loss的α和γ到底怎么调
星空图片上正样本(星点)和负样本(背景)的比值大约在 1:10 000 以上,比一般目标检测的场景还要极端。Focal Loss 虽然能平衡,但如果 α、β 不是配比合理,你会发现 loss 在收敛到一个很小的值后开始震荡,模型似乎把背景全部“压制”了,却也把最暗的星点也压掉了。
我用的是 α 从 2 降到 1.6、β 从 4 降到 3,并配合每张图至少采样 500 个正样本的训练策略。说白了,α 控制“背景错分的惩罚力度”,β 控制“难样本的关注程度”。对点状目标来说,暗弱星点本身就难,不需要把 β 调得太高,否则模型会陷入跟噪点死磕的循环,反而忽略了中等亮度的恒星。调参过程非常枯燥,但这是所有点检测项目绕不开的一步。我把最终参数整理成了一个小表格,方便参考:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 小目标漏检 | 暗星召回率低于 35% | 加FPN、多尺度训练 |
| 合成图片与真实图片差异 | 模型把热像素识别为星点 | 泊松噪声+域随机化 |
| 正负样本失衡 | Loss震荡、暗星被压制 | 调整FocalLoss的α/β,动态采样 |
5. 端侧部署:从PyTorch到TensorFlow Lite的量化踩坑
5.1 ONNX导出时的算子兼容问题
训练好的 StarNet 是纯 PyTorch 模型,但我要部署到手机上,第一步就是转 ONNX。原本以为这是最不起眼的步骤,结果翻了大车。我的 FPN 部分用了一个nn.Upsample,里面选了bilinear模式,这个算子在导出时是能正常导出的,但是到 TFLite 转换器那里就报“Unsupported operator: ResizeBilinear”。折腾了两天,最后把上采样替换成“转置卷积 + 固定 kernel”,或者先用最近邻上采样再跟一个 3×3 卷积做平滑,效果几乎一样,但各种设备上都能跑。
这里有个更隐蔽的坑:ONNX 的 opset version 必须和 TFLite 转换器兼容。我建议把 opset 固定在 11 或者 12,太高的话很多边缘设备的 NPU 驱动不支持。同时,导出时记得把 dynamic_axes 关掉,让输入输出都是静态尺寸,否则后续量化太痛苦。你如果不做量化只跑 float32,倒是无所谓,但作为手机部署,量化是绕不开的。
5.2 8bit量化后精度掉得不多,但有几个坑
我用代表性数据集做了 8bit 全整数量化,量化后模型大小从 14.8MB 降到 3.9MB,实测单帧耗时也从 68ms 降到 41ms,效果非常香。代价是热力图上有些最暗星点的峰值变弱,导致召回率掉了 4~5%。影响不大,但我建议你在量化前先看“热力图峰值分布”,如果峰值大多集中在 0.3~0.5 之间,量化后很可能低于阈值直接丢了。
解决方法是把训练时的阈值从 0.3 调低到 0.2,同时在后处理里增加一个“局部峰值排序”逻辑:对每个峰值周围 5×5 邻域内的所有候选点取最高分,而不是超过阈值就输出。这样量化损失主要影响的是分数,而不会直接改变输出的数量。最终精度对比如下:
| 部署方式 | 模型大小 | 单帧耗时(骁龙888) | 暗星召回率 |
|---|---|---|---|
| PyTorch FP32 | 14.8MB | 68ms | 88.2% |
| TFLite FP32 | 14.6MB | 49ms | 88.1% |
| TFLite INT8 | 3.9MB | 41ms | 83.6% |
5.3 实测帧率与精度对比表
上面表格里的是我自己的骁龙 888 测试机,iPhone 上因为走的 Core ML 加载精度略有不同。实测在 iPhone 12 上,INT8 模型单帧 512×512 耗时约 35ms,在 iPhone SE 2 上则是 44ms。这基本满足我用它做实时星野预览的需求。
有一点要注意:TFLite 的 GPU delegate 在部分安卓机型上不支持某些自定义算子的 int8 实现,硬跑会退回 CPU。因此我建议你部署前先用官方 TFLite 工具做一遍“算子测试”,把所有算子列出来,再把发生在 GPU 上运行有问题的算子强制指定为 CPU。这个操作能避免很多莫名其妙的崩溃。
6. 后续扩展:从“认星星”到“认星座”的工程化思考
6.1 星座匹配算法:基于星对角距的投票
StarNet 目前只能告诉我“哪里有星星、有多亮”,但用户真正想知道的往往是“这是什么星座”。这里我用的是星对角距匹配法,完全不依赖图像方向信息,只需要知道任意两颗星在像素坐标系下的距离,并结合相机焦距换算成角分。
具体思路是:从 StarNet 的输出结果中随机抽取 3 颗星组成三角形,再在星表里查找所有与这个三角形边长匹配的恒星组合,用投票方式选出最可能对应的星座。这个思路来自天文学里的“三角形星图识别法”,在星敏感器领域已经用了很多年。
星座匹配对 StarNet 的坐标精度要求比较高,偏移分支做得越准,边长匹配的容差范围就可以设得越小。我最后把偏移分支的 L1 Loss 权重提高到 0.2,星点中心坐标平均误差从 1.2 像素降到 0.7 像素,匹配成功率从 68% 升到了 91%。
6.2 还可以接入什么:与星野赤道仪联动、自动导星
有了实时星点识别,能做的事情一下子就多了。我现在在尝试把它接到星野赤道仪的导星接口上:每 500ms 获取一帧画面,用 StarNet 找到 20 颗以上的星,通过计算两帧之间星点坐标的偏移量,给赤道仪控制系统输出修正脉冲,实现自动导星。这个闭环一旦跑通,长曝光就不再需要人工盯守。
另外,我还打算把 StarNet 和手机自带的陀螺仪、GPS 融合起来,让用户把手机指向天空时,屏幕上能直接叠加星座名称和亮星标签。手机端的 ROM 足够装下这个 4MB 模型,实时性也已经达标,剩下只是工程问题。
如果你也想复现这个项目,我建议从第二章节的合成数据开始,先把 1000 张仿真星图训练跑通,再考虑增加真实照片。数据做扎实了,模型调参反而快。我第一次就是因为数据偷懒,后续补了整整两周的真实样本才把精度追回来。这个教训,可能比模型结构本身更值得记住。