Download satlink/API.md from lulidong/rs: direct link, hf CLI and curl.
- Browser
- Download file 17 kB
-
https://huggingface.co/lulidong/rs/resolve/main/satlink/API.md
- Command line
-
hf download hf://lulidong/rs/satlink/API.md
-
curl -L -o API.md https://huggingface.co/lulidong/rs/resolve/main/satlink/API.md
satlink 调用接口
面向外部应用的公开接口。三个对象覆盖整条链路:
| 对象 | 作用 | 规模 | 在哪一侧 |
|---|---|---|---|
satlink.Satellite |
像素 → 码字 | 5.9M 参数 | 星上 |
satlink.Ground |
码字 → 像素 | 337.8M 参数 | 地面 |
satlink.GroundVLM |
码字 → 答案 | 解码器 + 视觉塔 + LLM,一个整体 | 地面 |
支持两个 tokenizer 后端,由权重文件自动识别,不用传标志:
| 后端 | 权重 | 每片 | 单片下行 | 压缩比 | PSNR | 通路 |
|---|---|---|---|---|---|---|
opd One-D-Piece |
sat_encoder.pt / ground_decoder.pt |
48 tok × 12 bit | 72 B | ~910× | ~21 dB | 像素 + 隐空间 |
rstok RS-Tokenizer |
rstok_sat.pt / rstok_ground.pt |
256 tok × 18 bit | 576 B | ~114× | ~41 dB | 仅像素 |
多 8 倍带宽换约 20 dB。选哪个取决于链路和画质哪个更紧张。rstok 只接了像素通路——
隐空间通路的 bridge 是针对 One-D-Piece 那个特定的中间层特征训出来的,换 tokenizer 能跑但是错的。
两个值类型在它们之间传递:satlink.VQFrame(码字 + 摆放几何)和 satlink.LinkProfile
(帧长宽 / 切片大小 / 每片 token 数——星地约定好的常量,不上下行)。
import satlink 本身不加载 torch,也不加载任何模型;Satellite / Ground / GroundVLM
在首次访问时才解析。这不是为了好看:星上进程必须能 import 本包而不把 337.8M 参数的解码器
拉进内存,这一点由 satlink/bench/test_isolation.py 静态校验。
1. 星上
import satlink
sat = satlink.Satellite(
"satlink/weights/sat_encoder.pt",
32768, 30720, # 部署固定的帧尺寸
n_tok=48, # 每个 256x256 切片取多少 token
dtype="float16", # float16 / bfloat16 / float32
device="auto", # auto 会优先选 musa(M1000),其次 cuda,最后 cpu
)
frame = sat.encode_file("scene.png", batch=64) # PNG / BMP 都支持,任意尺寸
wire = sat.to_wire(frame) # 下行字节流,无包头
sat.save(frame, "codes.json") # 落盘(原子写入)
| 成员 | 说明 |
|---|---|
sat.encode_file(path, batch=64) |
流式读 PNG/BMP,一次一条 256 像素高的横条,峰值内存是一条带而不是整幅 |
sat.encode(image, batch=64) |
已在内存里的 PIL 图 / HxW、HxWx3 数组 / CHW 张量 |
sat.to_wire(frame) |
打包成下行字节 |
sat.save(frame, path, **extra) |
写 JSON,原子替换 |
sat.n_crops / sat.frame_bytes / sat.compression_ratio |
切片数 / 单帧下行字节 / 相对 8bit 灰度原图的压缩比 |
关于 batch。 它是一次推给编码器的切片数,用来在显存和吞吐之间取舍,可以随便调。
但它在数值上不是中性的:不同 batch 会选中不同的 GEMM kernel,其舍入差异会让落在量化边界
附近的码字翻转,实测约 0.2%–0.5%。平均重建影响 < 0.4/255,但局部单点最大可达 ~118/255。
结论是:不同 batch 的两次运行不保证逐位相同,验收判据应当看重建像素,而不是码字。
关于精度(重要,两个后端的约束相反)。
| 编码器 | 解码器 | |
|---|---|---|
opd |
fp16 / bf16 / fp32 均可 | fp16 / bf16 / fp32 均可(默认 bf16) |
rstok |
不能 bf16(用 fp16) | 不能 fp16(用 bf16,默认) |
rstok 编码器在 bf16 下,8 张参考图里有 1 张从 41.4 dB 塌到 22.5 dB——19 万码字在 24 维单位球面上
近邻的余弦间隔极小,bf16 的 8 位尾数分不开,argmin 会整块选错。fp16 全程与 fp32 差 <0.5 dB。
rstok 解码器反过来:1024 宽 24 层,激活会冲出 fp16 的动态范围,整帧变成 NaN,
然后被 cast 成一张全黑图。
所以解码器统一走 bf16——实测两个后端的 bf16 与 fp32 差 0.00 dB,显存减半,也绕开了上面这个坑。
Ground 和 run_ground 的默认值都是 bfloat16。保留两道护栏:RSTokDecoder 构造时拒绝 fp16,
Ground.decode 检查输出是否有限——非有限直接报错,而不是输出一张看起来「成功」的黑图。
(官方参考输出是 bf16 跑的,所以继承了编码器那个塌陷。test_rstok.py 把精确匹配钉在 bf16 上,
那是溯源检查,不是推荐用 bf16。)
关于图像格式。 PNG 和 BMP 都支持,且结果逐位一致(已验证)。两者读法不同:
BMP 未压缩、行有 4 字节对齐填充,可以 np.memmap 按需读某一条;PNG 是单条 zlib 流、
行间有 filter 依赖,无法定位到第 N 行,只能整幅解码一次再切。接口一样,代价不一样。
读取器返回原生通道数,不自己转灰度——转灰度用的是 satlink/sat/encoder.py 里的
float BT.601,属于模型契约的一部分;若在读取阶段用 PIL 的定点灰度替代,像素会差 1 LSB,
足以翻转码字,导致「流式编码」和「整幅编码」结果不一致。
2. 地面:重建
import satlink
frame = satlink.load_codes("codes.json") # 或 satlink.frame_from_wire(wire, 32768, 30720)
g = satlink.Ground("satlink/weights/ground_decoder.pt", dtype="float32")
img = g.reconstruct(frame) # PIL 灰度图
img.save("recon.png")
x = g.decode(frame) # 想要张量就用这个:[H, W],float,[0,1],重叠区已平均
3. 地面:直接问模型
vlm = satlink.GroundVLM("model/RSThinker",
decoder="satlink/weights/ground_decoder.pt")
print(vlm.ask(frame, "where is the ship"))
像素路径的模型是懒加载的。 它的解码器要绑定帧几何,而构造时还不知道,所以
GroundVLM(...) 会立刻返回、完全不碰 GPU,模型在第一次 ask() 时才上卡。
盯着 nvidia-smi 的人会觉得没加载。想立即加载就传 profile:
vlm = satlink.GroundVLM("model/RSThinker",
decoder="satlink/weights/ground_decoder.pt",
profile=satlink.LinkProfile(512, 512)) # 构造时就上卡
隐空间路径没有这个绑定,一直是构造时就加载。
合并检查点(推荐)。 像素路径要读基座 + 一个单独的 338M 解码器 .pt;隐空间路径每次还要
把 240 个 LoRA 权重折进基座。这些都不依赖运行时才知道的东西,所以预先合并成一份普通的
safetensors,加载时什么都不用合:
vlm = satlink.GroundVLM("model/RSThinker-satlink-pixel") # 不用传 decoder=
vlm = satlink.GroundVLM("model/RSThinker-satlink-latent") # 不用传 latent_ground=/lora=
合并检查点带一个 satlink.json 标记自己是哪条路,接口据此自动选。这时再传
decoder=/latent_ground=/lora= 会直接报错——不然到底用哪个解码器是有歧义的。
自己合并:
python -m satlink.tools.export_merged --path pixel --model model/RSThinker \
--decoder satlink/weights/ground_decoder.pt --out model/RSThinker-satlink-pixel
python -m satlink.tools.export_merged --path latent --model model/RSThinker \
--ground satlink/weights/latent_ground_dec12.pt \
--lora outputs/latent_sft_dec12/checkpoint-2241 --out model/RSThinker-satlink-latent
| 加载方式 | 耗时 | 权重 |
|---|---|---|
| 像素 分离式 | 9.4 s | — |
| 像素 合并式 | 7.3 s | 与分离式逐张完全一致 |
| 隐空间 分离式 + LoRA | 15.9 s | — |
| 隐空间 合并式 | 6.9 s(2.3×) | 与分离式逐张完全一致 |
加载开销(10.63B 模型,A100):
| 显存 | 峰值主机内存 | 耗时 | |
|---|---|---|---|
GroundVLM(...) 不带 profile |
0 | ~0 | 0.3 s |
首次 ask() / 带 profile 构造 |
21.3 GB | 5.6 GB | ~10–15 s |
两条路径二选一,不能都给:
# 像素路径:码字在模型内部解成像素再嵌入
satlink.GroundVLM(model_dir, decoder="satlink/weights/ground_decoder.pt")
# 隐空间路径:只做部分解码,直接投影进视觉塔的 patch-embedding 空间,全程不产生像素
satlink.GroundVLM(model_dir,
latent_ground="satlink/weights/latent_ground_dec12.pt",
lora="outputs/latent_sft_dec12/checkpoint-2241")
4. 协议层
链路上流动的只有码字:每个 256×256 切片 48 个索引 × 12 bit = 72 字节,相对灰度原图约 910×。 没有包头——帧尺寸、切片大小、每片 token 数、码本位宽都是两端已经约定的常量, 花下行字节去传一个永远相同的头是纯开销。切片序号和帧边界由流中的位置隐含决定。
from satlink import LinkProfile, pack_frame, unpack_frame
p = LinkProfile(32768, 30720, tile=256, n_tok=48, bits=12)
p.grid # (128, 120) —— ceil(W/256) x ceil(H/256)
p.n_tiles # 15360
p.crop_bytes # 72
p.frame_bytes # 1105920 (1.05 MiB)
p.tile_origin(i) # 第 i 个切片的左上角
切片是重叠的:最后一行/列与边缘齐平,而不是把余量补边。补边会让 tokenizer 把预算花在 编码一条复制出来的条带上,而那条带的重建会渗进真实像素里。边长是 256 整数倍时重叠为零 (32768×30720 正是如此,所以能拿到完整的 910×);不是整数倍时会有冗余,比如 600×600 被 3×3 切片覆盖了 768 像素的内容,多花 1.64× 的 token,压缩比相应降到 555×。
5. 设备
from satlink import resolve_device
resolve_device("auto") # 'musa' (M1000, 需要 torch_musa) / 'cuda' / 'cpu'
device="auto" 优先选 MUSA。显式传 device="musa" 而 torch_musa 没能 import 时会直接报错,
不会静默退回 CPU——验收场景下静默降级比失败更糟。
6. 全流程脚本
一个驱动跑通全链路,星上阶段可以单独跑并落盘,地面阶段只从落盘产物恢复。
# 全流程
python -m satlink.examples.pipeline --image scene.png --out run1
# 只跑星上,下行产物全部落盘
python -m satlink.examples.pipeline --image scene.png --out run1 --stage sat --dump-crops
# 只跑地面 —— 不碰原图,也不加载星上模型
python -m satlink.examples.pipeline --out run1 --stage ground \
--model model/RSThinker --question "where is the ship"
# 换 RS-Tokenizer 后端
python -m satlink.examples.pipeline --image scene.bmp --out run2 \
--sat-weights satlink/weights/rstok_sat.pt \
--decoder satlink/weights/rstok_ground.pt
落盘内容:
| 文件 | 内容 |
|---|---|
manifest.json |
profile / 后端 / 精度 / 各段耗时 / 字节数 / 压缩比 / 地面结果 |
codes.json |
可读形式的码字 |
downlink.json |
打包后的下行比特流,十六进制字符串放在 JSON 里——电台真正发的东西,但能用文本编辑器打开 |
crops/ |
--dump-crops 时,编码器实际看到的每张 256×256 灰度图 |
recon.png / compare.png / answer.txt |
地面产物 |
两处是刻意的:
--stage ground不接受--image。 地面能不能只靠下行数据还原,是这套拆分成立与否的判据; 它要是偷看原图,测的就不是链路了。- 地面会先核对
downlink.json和codes.json解出的码字是否一致,不一致就拒绝继续。 两者是同一份数据的两种形态,只有一条错比两条都错更难发现。
实测(1024×768,同一张图):
opd 12 片 x 48 tok x 12 b = 864 B 910.2x 20.80 dB
rstok 12 片 x 256 tok x 18 b = 6912 B 113.8x 27.56 dB
7. 内置示例
satlink/assets/showcase/ 里有 11 张 VRSBench 影像,以及模型从 satlink 码字给出的回答,
VQA 和 grounding 都有。装完不接任何评测数据就能验证这套东西是通的。
satlink/assets/showcase/
showcase.json 11 个例子的问题、标准答案、模型回答、IoU、各后端重建指标
images/ 原图 11 张
recon/ 每张图两个后端的重建,共 22 张
| VQA | 31 题,覆盖 8 种题型(数量/位置/类别/存在/颜色/尺寸/方向/场景) |
| Grounding | 24 题,平均 IoU 0.684,最高 1.000,最低 0.519 |
| 重建 | opd 平均 19.4 dB @ 910×;rstok 平均 25.1 dB @ 114× |
只收录答对的题——按 VRSBench 标准答案判定,grounding 要 IoU≥0.5,VQA 用 vqa_ok。
参考答案要是错的,读的人就分不清「真的退化了」和「基线本来就差」,而且那些数字会被当成模型准确率引用。
这是演示集,不是评测集:11 张图量不出准确率,那是 satlink/eval/ 在几千条上做的事。
python -m satlink.bench.test_showcase # 只查重建,不需要 VLM
python -m satlink.bench.test_showcase --backend both
python -m satlink.bench.test_showcase --model model/RSThinker # 再把 55 题全问一遍
重建按 PSNR 与记录值的偏差判定,不按码字。码字是大码本上的 argmin,换 GPU、换精度、换 batch 都会翻一部分,而图几乎不动;钉码字会让这个测试在除了打包那台机器以外的所有机器上失败。
关于 VLM 那一腿有个实测结论值得记着:回答在重新分批后不是逐字可复现的。同样的图、同样的
提示词、temperature=0,batch 组成一变,数值路径就变,350 token 的推理链里早期翻一个 token
就足以改掉最终答案——实测 61 题里 12 题措辞不同、6 题结论翻转。所以参考答案是用
--rerecord-batch 1 从打包好的那张图重录的,测试也默认 batch=1。当前状态:55/55 仍判正确,
54/55 逐字相同。
8. 验收脚本
星地两侧分开跑,因为星上那台机器没有解码器也没有 LLM——这正是拆分的意义。
地面跑完用 --append-to 把重建和 LLM 结果并回该用例的报告,做到一个用例一份报告。
# 用例1 · 检测网络性能测试(F01 启动与离线读取 / F02 离散Token压缩 / F03 结果输出与存储)
python -m satlink.accept.run_sat --case 1 --data /mnt/ssd/testdata1 \
--out results/case1 --dtype float16 --batch 64
# 用例2 · 软件系统功能测试(另加 F03 ≥5min 连续循环 / F04 资源与功耗监控 / F05 输出与存储)
python -m satlink.accept.run_sat --case 2 --data /mnt/ssd/testdata2 \
--out results/case2 --min-seconds 300 --dtype float16 --batch 64
# 地面:重建 + LLM,并合并回对应用例的报告
python -m satlink.accept.run_ground --codes results/case1/codes \
--out results/case1/ground --source /mnt/ssd/testdata1 \
--model model/RSThinker --append-to results/case1/report.json
每条判据都在代码里判定并写进 report.json,只有重建的视觉检查留给人看
(图在 results/*/ground/recon/)。
几点是刻意这么做的:
- 功耗采不到时判为失败,不判为通过。 从
/sys/class/hwmon/hwmonN/{in1_input,curr1_input}(mV × mA)1 Hz 采样,判据平均 ≤77.5 W、峰值 ≤90.5 W。传感器缺失时报告会写明「未能采集」 并判失败——验收报告把「没测到」记成「合格」,比测出不合格更危险。 - 故意塞一个损坏文件。 F01 会自己构造一个坏 PNG,要求读取器明确报错; 若被当成可用输入而静默跳过,该步判失败。这防的是「伪成功」。
- 输出是原子写入。 先写
.tmp再os.replace,读者要么看到旧文件、要么看到完整新文件, 不会读到写了一半的结果。报告里会检查有没有残留.tmp。 - 压缩比判据与帧尺寸有关。 失败时报告会算出重叠冗余系数并给出「若边长为 256 整数倍则为 多少」,免得看到一个数字却不知道为什么不达标。
已验证的行为
在 A100 上以 1024×768(256 的整数倍)实测:
| 项 | 结果 |
|---|---|
| 压缩比 | 910.2×(判据 ≥900) |
| PNG 与 BMP 编码结果 | 逐位一致 |
| PNG 与 BMP 重建 PSNR | 均为 20.80 dB |
| PNG 与 BMP 的 LLM 回答 | 完全相同 |
流式编码 vs 整幅 encode() |
batch ≥8 时逐位一致 |
| 32768×30720 单帧 (opd) | 2.9 s,5365 crop/s,下行 1.11 MB,910.2×,峰值显存 20.16 GB |
| 同一张 1024×768 上两个后端 | opd 864 B / 910.2× / 20.80 dB;rstok 6912 B / 113.8× / 27.56 dB |
| rstok vendor 正确性 | bf16 下码字与官方参考 100.00% 一致;用官方码字解码与官方重建差 0.36/255 |
batch=1 是唯一的例外:约 0.2%–0.5% 的码字会翻转(见上文「关于 batch」),
平均重建影响 <0.4/255。判据看的是重建像素,不是码字。