File size: 16,954 Bytes
5261e6a
 
 
 
 
 
 
 
 
 
61a093f
 
 
 
 
 
 
 
 
 
5261e6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61a093f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5261e6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6ff06f
 
 
 
 
 
 
 
 
 
 
 
85356e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6ff06f
 
 
 
 
 
 
5261e6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bcc382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9473469
6bcc382
 
 
 
 
 
 
9473469
6bcc382
 
 
 
 
 
 
 
 
 
 
9473469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5261e6a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61a093f
 
 
5261e6a
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# 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。判据看的是重建像素,不是码字。