ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于CenterNet的轻量级星点检测模型StarNet实战

基于CenterNet的轻量级星点检测模型StarNet实战 在电脑前坐了一整夜导出相机里的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_channels64): super().__init__() self.heatmap nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 1, 1), ) self.offset nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 2, 1), ) self.magnitude nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), 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的α和γ到底怎么调星空图片上正样本星点和负样本背景的比值大约在 110 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 FP3214.8MB68ms88.2%TFLite FP3214.6MB49ms88.1%TFLite INT83.9MB41ms83.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 张仿真星图训练跑通再考虑增加真实照片。数据做扎实了模型调参反而快。我第一次就是因为数据偷懒后续补了整整两周的真实样本才把精度追回来。这个教训可能比模型结构本身更值得记住。
返回列表