rs / satlink /API.md
lulidong's picture
satlink: merged-checkpoint support (export_merged + auto-detect on load); shape-only decoder/bridge construction
85356e8 verified
|
Raw History Blame Contribute Delete
17 kB

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。判据看的是重建像素,不是码字。