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