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. 星上
```python
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. 地面:重建
```python
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. 地面:直接问模型
```python
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`:
```python
vlm = satlink.GroundVLM("model/RSThinker",
decoder="satlink/weights/ground_decoder.pt",
profile=satlink.LinkProfile(512, 512)) # 构造时就上卡
```
隐空间路径没有这个绑定,一直是构造时就加载。
**合并检查点(推荐)。** 像素路径要读基座 + 一个单独的 338M 解码器 `.pt`;隐空间路径每次还要
把 240 个 LoRA 权重折进基座。这些都不依赖运行时才知道的东西,所以预先合并成一份普通的
safetensors,加载时什么都不用合:
```python
vlm = satlink.GroundVLM("model/RSThinker-satlink-pixel") # 不用传 decoder=
vlm = satlink.GroundVLM("model/RSThinker-satlink-latent") # 不用传 latent_ground=/lora=
```
合并检查点带一个 `satlink.json` 标记自己是哪条路,接口据此自动选。这时再传
`decoder=`/`latent_ground=`/`lora=` 会直接报错——不然到底用哪个解码器是有歧义的。
自己合并:
```bash
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 |
两条路径二选一,不能都给:
```python
# 像素路径:码字在模型内部解成像素再嵌入
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 数、码本位宽都是两端已经约定的常量,
花下行字节去传一个永远相同的头是纯开销。切片序号和帧边界由**流中的位置**隐含决定。
```python
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. 设备
```python
from satlink import resolve_device
resolve_device("auto") # 'musa' (M1000, 需要 torch_musa) / 'cuda' / 'cpu'
```
`device="auto"` 优先选 MUSA。显式传 `device="musa"` 而 `torch_musa` 没能 import 时会直接报错,
不会静默退回 CPU——验收场景下静默降级比失败更糟。
---
## 6. 全流程脚本
一个驱动跑通全链路,星上阶段可以单独跑并落盘,地面阶段只从落盘产物恢复。
```bash
# 全流程
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/` 在几千条上做的事。
```bash
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 结果并回该用例的报告,做到**一个用例一份报告**。
```bash
# 用例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。判据看的是重建像素,不是码字。