lulidong commited on
Commit
61a093f
·
verified ·
1 Parent(s): 5261e6a

satlink: add RS-Tokenizer backend (256x18b, ~114x, ~41dB) alongside One-D-Piece; pixel path only

Browse files
satlink/API.md CHANGED
@@ -8,6 +8,16 @@
8
  | `satlink.Ground` | 码字 → 像素 | 337.8M 参数 | 地面 |
9
  | `satlink.GroundVLM` | 码字 → 答案 | 解码器 + 视觉塔 + LLM,一个整体 | 地面 |
10
 
 
 
 
 
 
 
 
 
 
 
11
  两个值类型在它们之间传递:`satlink.VQFrame`(码字 + 摆放几何)和 `satlink.LinkProfile`
12
  (帧长宽 / 切片大小 / 每片 token 数——星地**约定好的常量,不上下行**)。
13
 
@@ -48,6 +58,26 @@ sat.save(frame, "codes.json") # 落盘(原子写入)
48
  附近**的码字翻转,实测约 0.2%–0.5%。平均重建影响 < 0.4/255,但局部单点最大可达 ~118/255。
49
  结论是:不同 batch 的两次运行**不保证逐位相同**,验收判据应当看重建像素,而不是码字。
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  **关于图像格式。** PNG 和 BMP 都支持,且**结果逐位一致**(已验证)。两者读法不同:
52
  BMP 未压缩、行有 4 字节对齐填充,可以 `np.memmap` 按需读某一条;PNG 是单条 zlib 流、
53
  行间有 filter 依赖,无法定位到第 N 行,只能整幅解码一次再切。接口一样,代价不一样。
@@ -176,7 +206,9 @@ python -m satlink.accept.run_ground --codes results/case1/codes \
176
  | PNG 与 BMP 重建 PSNR | 均为 20.80 dB |
177
  | PNG 与 BMP 的 LLM 回答 | 完全相同 |
178
  | 流式编码 vs 整幅 `encode()` | batch ≥8 时逐位一致 |
179
- | 32768×30720 单帧 | 2.9 s,5365 crop/s,下行 1.11 MB,910.2×,峰值显存 20.16 GB |
 
 
180
 
181
  `batch=1` 是唯一的例外:约 0.2%–0.5% 的码字会翻转(见上文「关于 batch」),
182
  平均重建影响 <0.4/255。判据看的是重建像素,不是码字。
 
8
  | `satlink.Ground` | 码字 → 像素 | 337.8M 参数 | 地面 |
9
  | `satlink.GroundVLM` | 码字 → 答案 | 解码器 + 视觉塔 + LLM,一个整体 | 地面 |
10
 
11
+ 支持两个 tokenizer 后端,由权重文件自动识别,不用传标志:
12
+
13
+ | 后端 | 权重 | 每片 | 单片下行 | 压缩比 | PSNR | 通路 |
14
+ |---|---|---|---|---|---|---|
15
+ | `opd` One-D-Piece | `sat_encoder.pt` / `ground_decoder.pt` | 48 tok × 12 bit | 72 B | ~910× | ~21 dB | 像素 + 隐空间 |
16
+ | `rstok` RS-Tokenizer | `rstok_sat.pt` / `rstok_ground.pt` | 256 tok × 18 bit | 576 B | ~114× | ~41 dB | **仅像素** |
17
+
18
+ 多 8 倍带宽换约 20 dB。选哪个取决于链路和画质哪个更紧张。`rstok` 只接了像素通路——
19
+ 隐空间通路的 bridge 是针对 One-D-Piece 那个特定的中间层特征训出来的,换 tokenizer 能跑但是错的。
20
+
21
  两个值类型在它们之间传递:`satlink.VQFrame`(码字 + 摆放几何)和 `satlink.LinkProfile`
22
  (帧长宽 / 切片大小 / 每片 token 数——星地**约定好的常量,不上下行**)。
23
 
 
58
  附近**的码字翻转,实测约 0.2%–0.5%。平均重建影响 < 0.4/255,但局部单点最大可达 ~118/255。
59
  结论是:不同 batch 的两次运行**不保证逐位相同**,验收判据应当看重建像素,而不是码字。
60
 
61
+ **关于精度(重要,两个后端的约束相反)。**
62
+
63
+ | | 编码器 | 解码器 |
64
+ |---|---|---|
65
+ | `opd` | fp16 / bf16 / fp32 均可 | fp16 / bf16 / fp32 均可(默认 bf16) |
66
+ | `rstok` | **不能 bf16**(用 fp16) | **不能 fp16**(用 bf16,默认) |
67
+
68
+ `rstok` 编码器在 bf16 下,8 张参考图里有 1 张从 41.4 dB 塌到 22.5 dB——19 万码字在 24 维单位球面上
69
+ 近邻的余弦间隔极小,bf16 的 8 位尾数分不开,argmin 会整块选错。fp16 全程与 fp32 差 <0.5 dB。
70
+
71
+ `rstok` 解码器反过来:1024 宽 24 层,激活会冲出 fp16 的动态范围,**整帧变成 NaN**,
72
+ 然后被 cast 成一张全黑图。
73
+
74
+ **所以解码器统一走 bf16**——实测两个后端的 bf16 与 fp32 差 0.00 dB,显存减半,也绕开了上面这个坑。
75
+ `Ground` 和 `run_ground` 的默认值都是 `bfloat16`。保留两道护栏:`RSTokDecoder` 构造时拒绝 fp16,
76
+ `Ground.decode` 检查输出是否有限——非有限直接报错,而不是输出一张看起来「成功」的黑图。
77
+
78
+ (官方参考输出是 bf16 跑的,所以继承了编码器那个塌陷。`test_rstok.py` 把精确匹配钉在 bf16 上,
79
+ 那是**溯源检查**,不是推荐用 bf16。)
80
+
81
  **关于图像格式。** PNG 和 BMP 都支持,且**结果逐位一致**(已验证)。两者读法不同:
82
  BMP 未压缩、行有 4 字节对齐填充,可以 `np.memmap` 按需读某一条;PNG 是单条 zlib 流、
83
  行间有 filter 依赖,无法定位到第 N 行,只能整幅解码一次再切。接口一样,代价不一样。
 
206
  | PNG 与 BMP 重建 PSNR | 均为 20.80 dB |
207
  | PNG 与 BMP 的 LLM 回答 | 完全相同 |
208
  | 流式编码 vs 整幅 `encode()` | batch ≥8 时逐位一致 |
209
+ | 32768×30720 单帧 (opd) | 2.9 s,5365 crop/s,下行 1.11 MB,910.2×,峰值显存 20.16 GB |
210
+ | 同一张 1024×768 上两个后端 | opd 864 B / 910.2× / 20.80 dB;rstok 6912 B / 113.8× / 27.56 dB |
211
+ | rstok vendor 正确性 | bf16 下码字与官方参考 **100.00%** 一致;用官方码字解码与官方重建差 0.36/255 |
212
 
213
  `batch=1` 是唯一的例外:约 0.2%–0.5% 的码字会翻转(见上文「关于 batch」),
214
  平均重建影响 <0.4/255。判据看的是重建像素,不是码字。
satlink/accept/run_ground.py CHANGED
@@ -59,9 +59,11 @@ def main():
59
  ap = argparse.ArgumentParser()
60
  ap.add_argument("--codes", required=True, help="directory of codes .json from the satellite")
61
  ap.add_argument("--out", required=True)
62
- ap.add_argument("--decoder", default="satlink/weights/ground_decoder.pt")
 
63
  ap.add_argument("--source", default=None, help="original frames, for PSNR (optional)")
64
- ap.add_argument("--dtype", default="float16", choices=["float16", "bfloat16", "float32"])
 
65
  ap.add_argument("--device", default="auto")
66
  ap.add_argument("--psnr-min", type=float, default=15.0,
67
  help="floor for the reconstruction check; see the note in the report")
@@ -91,6 +93,11 @@ def main():
91
  # ------------------------------------------------------------ reconstruction
92
  s = Step("R1", "地面重建与视觉检查")
93
  s.check(len(files) > 0, f"读取到 {len(files)} 个码流文件")
 
 
 
 
 
94
  g = Ground(a.decoder, device=device, dtype=a.dtype)
95
  rows = []
96
  from PIL import Image
 
59
  ap = argparse.ArgumentParser()
60
  ap.add_argument("--codes", required=True, help="directory of codes .json from the satellite")
61
  ap.add_argument("--out", required=True)
62
+ ap.add_argument("--decoder", default="satlink/weights/ground_decoder.pt",
63
+ help="ground_decoder.pt (One-D-Piece) or rstok_ground.pt (RS-Tokenizer)")
64
  ap.add_argument("--source", default=None, help="original frames, for PSNR (optional)")
65
+ ap.add_argument("--dtype", default="bfloat16", choices=["float16", "bfloat16", "float32"],
66
+ help="decoder precision; bfloat16 matches float32 to 0.01 dB on both backends")
67
  ap.add_argument("--device", default="auto")
68
  ap.add_argument("--psnr-min", type=float, default=15.0,
69
  help="floor for the reconstruction check; see the note in the report")
 
93
  # ------------------------------------------------------------ reconstruction
94
  s = Step("R1", "地面重建与视觉检查")
95
  s.check(len(files) > 0, f"读取到 {len(files)} 个码流文件")
96
+ # bfloat16 throughout: identical to float32 within 0.01 dB on both backends, and it avoids
97
+ # the float16 overflow that turns an RS-Tokenizer decode into NaN.
98
+ from ..api import _sniff
99
+ backend = _sniff(a.decoder)
100
+ print(f" backend {backend}")
101
  g = Ground(a.decoder, device=device, dtype=a.dtype)
102
  rows = []
103
  from PIL import Image
satlink/accept/run_sat.py CHANGED
@@ -32,7 +32,6 @@ import torch
32
 
33
  from ..device import name as dev_name, resolve
34
  from ..protocol import LinkProfile
35
- from ..sat.encoder import SatEncoder
36
  from ..api import encode_one
37
  from ..imageio import StripReader
38
  from .power import PowerMonitor
@@ -96,8 +95,11 @@ def main():
96
  ap.add_argument("--case", type=int, required=True, choices=[1, 2])
97
  ap.add_argument("--data", required=True, help="input directory on the SSD")
98
  ap.add_argument("--out", required=True, help="output directory for codes and the report")
99
- ap.add_argument("--weights", default="satlink/weights/sat_encoder.pt")
100
- ap.add_argument("--n-tok", type=int, default=48)
 
 
 
101
  ap.add_argument("--dtype", default="float16", choices=["float16", "bfloat16", "float32"])
102
  ap.add_argument("--batch", type=int, default=64, help="crops per encoder call")
103
  ap.add_argument("--device", default="auto", help="auto | musa | cuda | cpu")
@@ -153,9 +155,23 @@ def main():
153
  _finish(a, steps, t_start, device, 1)
154
  return 1
155
 
156
- sat = SatEncoder(a.weights, LinkProfile(*readable[0][1].im.size, n_tok=a.n_tok),
157
- device=device, dtype=getattr(torch, a.dtype))
 
 
 
 
 
 
 
 
 
 
 
158
  print(f" model {sum(p.numel() for p in sat.parameters())/1e6:.2f}M params on {device}")
 
 
 
159
 
160
  # ---------------------------------------------------------------- F02 / loop
161
  s2 = Step("F02", "离散Token压缩")
@@ -166,7 +182,7 @@ def main():
166
  while True:
167
  rounds += 1
168
  for path, reader in readable:
169
- prof = LinkProfile(reader.width, reader.height, n_tok=a.n_tok)
170
  sat.profile = prof
171
  frame, secs, peak = encode_one(sat, reader, a.batch)
172
  raw_bits = reader.width * reader.height * 8 # grayscale, the conservative
@@ -176,6 +192,7 @@ def main():
176
  rec = {"input": os.path.abspath(path), "width": reader.width,
177
  "height": reader.height, "grid": list(prof.grid),
178
  "n_crops": prof.n_tiles, "n_tok": prof.n_tok, "tile": prof.tile,
 
179
  "raw_bits": raw_bits, "token_bits": tok_bits, "ratio": ratio,
180
  "seconds": secs, "crops_per_s": prof.n_tiles / max(secs, 1e-9),
181
  "peak_bytes": peak, "round": rounds}
@@ -185,6 +202,7 @@ def main():
185
  _atomic_write_json(out, {
186
  "image": os.path.basename(path), "width": reader.width,
187
  "height": reader.height, "n_tok": prof.n_tok, "dtype": a.dtype,
 
188
  "tile_origins": frame.tile_origins(), "codes": frame.tolist()})
189
  rec["codes"] = os.path.abspath(out)
190
  records.append(rec)
@@ -248,12 +266,12 @@ def main():
248
  j = json.load(open(r["codes"]))
249
  n = len(j["codes"])
250
  assert n == r["n_crops"] and all(len(c) == r["n_tok"] for c in j["codes"])
251
- assert all(0 <= v < 4096 for c in j["codes"] for v in c)
252
  ok_files += 1
253
  except Exception as e:
254
  parse_bad.append(f"{os.path.basename(r['codes'])}: {type(e).__name__}")
255
  s3.check(ok_files == len(first),
256
- f"{ok_files}/{len(first)} 个结果文件可解析、字段完整、码字在 0..4095 内"
257
  + ("" if not parse_bad else f" 失败: {parse_bad[:3]}"))
258
  s3.check(not any(f.endswith(".tmp") for f in os.listdir(codes_dir)),
259
  "无遗留 .tmp -- 全部为原子写入(先写临时文件再 rename)")
 
32
 
33
  from ..device import name as dev_name, resolve
34
  from ..protocol import LinkProfile
 
35
  from ..api import encode_one
36
  from ..imageio import StripReader
37
  from .power import PowerMonitor
 
95
  ap.add_argument("--case", type=int, required=True, choices=[1, 2])
96
  ap.add_argument("--data", required=True, help="input directory on the SSD")
97
  ap.add_argument("--out", required=True, help="output directory for codes and the report")
98
+ ap.add_argument("--weights", default="satlink/weights/sat_encoder.pt",
99
+ help="sat_encoder.pt (One-D-Piece) or rstok_sat.pt (RS-Tokenizer); the "
100
+ "backend and its token geometry are read from the file")
101
+ ap.add_argument("--n-tok", type=int, default=0,
102
+ help="override tokens per crop; 0 = whatever the backend requires")
103
  ap.add_argument("--dtype", default="float16", choices=["float16", "bfloat16", "float32"])
104
  ap.add_argument("--batch", type=int, default=64, help="crops per encoder call")
105
  ap.add_argument("--device", default="auto", help="auto | musa | cuda | cpu")
 
155
  _finish(a, steps, t_start, device, 1)
156
  return 1
157
 
158
+ # Which tokenizer this is comes from the checkpoint, not from a flag the operator has to keep
159
+ # in sync with --weights. Each backend pins its own tokens-per-crop and codebook width.
160
+ from ..api import TOKENIZERS, _sniff, profile_for
161
+ backend = _sniff(a.weights)
162
+ n_tok = a.n_tok or TOKENIZERS[backend]["n_tok"]
163
+ if backend == "rstok":
164
+ from ..sat.rstok import RSTokEncoder as E
165
+ else:
166
+ from ..sat.encoder import SatEncoder as E
167
+ sat = E(a.weights, profile_for(backend, *readable[0][1].im.size, n_tok=n_tok),
168
+ device=device, dtype=getattr(torch, a.dtype))
169
+ print(f" backend {backend}: {n_tok} tok x {sat.profile.bits} b = "
170
+ f"{sat.profile.crop_bytes} B/crop")
171
  print(f" model {sum(p.numel() for p in sat.parameters())/1e6:.2f}M params on {device}")
172
+ if backend == "rstok" and a.dtype == "bfloat16":
173
+ raise SystemExit("RS-Tokenizer 在 bf16 下会有整块 crop 塌陷(实测 8 张里 1 张 41.4->22.5 dB)"
174
+ ",请用 --dtype float16")
175
 
176
  # ---------------------------------------------------------------- F02 / loop
177
  s2 = Step("F02", "离散Token压缩")
 
182
  while True:
183
  rounds += 1
184
  for path, reader in readable:
185
+ prof = profile_for(backend, reader.width, reader.height, n_tok=n_tok)
186
  sat.profile = prof
187
  frame, secs, peak = encode_one(sat, reader, a.batch)
188
  raw_bits = reader.width * reader.height * 8 # grayscale, the conservative
 
192
  rec = {"input": os.path.abspath(path), "width": reader.width,
193
  "height": reader.height, "grid": list(prof.grid),
194
  "n_crops": prof.n_tiles, "n_tok": prof.n_tok, "tile": prof.tile,
195
+ "bits": prof.bits, "backend": backend,
196
  "raw_bits": raw_bits, "token_bits": tok_bits, "ratio": ratio,
197
  "seconds": secs, "crops_per_s": prof.n_tiles / max(secs, 1e-9),
198
  "peak_bytes": peak, "round": rounds}
 
202
  _atomic_write_json(out, {
203
  "image": os.path.basename(path), "width": reader.width,
204
  "height": reader.height, "n_tok": prof.n_tok, "dtype": a.dtype,
205
+ "bits": prof.bits, "tile": prof.tile, "backend": backend,
206
  "tile_origins": frame.tile_origins(), "codes": frame.tolist()})
207
  rec["codes"] = os.path.abspath(out)
208
  records.append(rec)
 
266
  j = json.load(open(r["codes"]))
267
  n = len(j["codes"])
268
  assert n == r["n_crops"] and all(len(c) == r["n_tok"] for c in j["codes"])
269
+ assert all(0 <= v < (1 << r["bits"]) for c in j["codes"] for v in c)
270
  ok_files += 1
271
  except Exception as e:
272
  parse_bad.append(f"{os.path.basename(r['codes'])}: {type(e).__name__}")
273
  s3.check(ok_files == len(first),
274
+ f"{ok_files}/{len(first)} 个结果文件可解析、字段完整、码字在码本范围内"
275
  + ("" if not parse_bad else f" 失败: {parse_bad[:3]}"))
276
  s3.check(not any(f.endswith(".tmp") for f in os.listdir(codes_dir)),
277
  "无遗留 .tmp -- 全部为原子写入(先写临时文件再 rename)")
satlink/api.py CHANGED
@@ -43,8 +43,49 @@ from .frame import VQFrame
43
  from .imageio import StripReader
44
  from .protocol import LinkProfile, pack_frame, unpack_frame
45
 
46
- __all__ = ["Satellite", "Ground", "GroundVLM", "VQFrame", "LinkProfile",
47
- "save_codes", "load_codes", "frame_from_wire", "read_image", "encode_one"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  # ---------------------------------------------------------------------------- codes on disk
@@ -77,14 +118,20 @@ def load_codes(path):
77
  return VQFrame(torch.tensor(j["codes"], dtype=torch.long), prof)
78
 
79
 
80
- def frame_from_wire(wire, width, height, n_tok=48, tile=256, bits=12):
81
  """-> VQFrame, from packed downlink bytes.
82
 
83
- width/height are supplied by the caller because the stream does NOT carry them: frame size,
84
- tile size and token count are deployment constants agreed by both ends, so spending downlink
85
- bytes on a header that always says the same thing is pure overhead."""
 
 
 
 
 
 
86
  import torch
87
- prof = LinkProfile(width, height, tile=tile, n_tok=n_tok, bits=bits)
88
  return VQFrame(torch.tensor(unpack_frame(prof, wire), dtype=torch.long), prof)
89
 
90
 
@@ -171,13 +218,19 @@ class Satellite:
171
  two runs at different batch sizes are not required to be bit-identical.
172
  """
173
 
174
- def __init__(self, weights, width, height, n_tok=48, tile=256, bits=12,
175
- device="auto", dtype="float16"):
176
  import torch
177
- from .sat.encoder import SatEncoder
178
- self.profile = LinkProfile(width, height, tile=tile, n_tok=n_tok, bits=bits)
 
 
179
  dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
180
- self.encoder = SatEncoder(weights, self.profile, device=device, dtype=dt)
 
 
 
 
181
  self.device, self.dtype = self.encoder.device, dt
182
 
183
  @property
@@ -221,15 +274,24 @@ class Satellite:
221
 
222
  # ---------------------------------------------------------------------------- ground
223
  class Ground:
224
- """Ground side, pixel reconstruction only. Codes in, image out."""
225
 
226
- def __init__(self, weights, device="auto", dtype="float32"):
 
 
 
 
 
227
  import torch
228
  from .device import resolve
229
- from .ground.decoder import GroundDecoder
230
  dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
231
  self.device = resolve(device)
232
- self.decoder = GroundDecoder(weights, dtype=dt).to(self.device)
 
 
 
 
233
 
234
  def decode(self, frame):
235
  """-> float tensor [H, W] in [0, 1], overlaps averaged."""
@@ -243,7 +305,16 @@ class Ground:
243
  # discarding two thirds of the output.
244
  while x.ndim > 3:
245
  x = x[0]
246
- return x.mean(0) if x.ndim == 3 else x
 
 
 
 
 
 
 
 
 
247
 
248
  def reconstruct(self, frame):
249
  """-> PIL grayscale image."""
@@ -268,6 +339,10 @@ class GroundVLM:
268
  from .device import resolve
269
  if bool(decoder) == bool(latent_ground):
270
  raise ValueError("give exactly one of decoder= or latent_ground=")
 
 
 
 
271
  self.device = resolve(device)
272
  self.model_dir = model_dir
273
  self.latent = bool(latent_ground)
 
43
  from .imageio import StripReader
44
  from .protocol import LinkProfile, pack_frame, unpack_frame
45
 
46
+ __all__ = ["Satellite", "Ground", "GroundVLM", "VQFrame", "LinkProfile", "TOKENIZERS",
47
+ "save_codes", "load_codes", "frame_from_wire", "read_image", "encode_one",
48
+ "profile_for"]
49
+
50
+
51
+ # ---------------------------------------------------------------------------- tokenizer backends
52
+ # Two tokenizers are supported and they are NOT interchangeable at a given profile -- each pins its
53
+ # own tokens-per-crop and codebook width, so the profile is derived from the backend rather than
54
+ # passed in and hoped for.
55
+ #
56
+ # opd One-D-Piece 48 tokens x 12 bits = 72 B/crop, ~910x, ~21 dB
57
+ # rstok RS-Tokenizer 256 tokens x 18 bits = 576 B/crop, ~114x, ~41 dB
58
+ #
59
+ # 8x the downlink buys about 20 dB. Which one is right depends on whether the link or the imagery
60
+ # is the scarce resource, so both ship.
61
+ TOKENIZERS = {
62
+ "opd": {"n_tok": 48, "bits": 12, "tile": 256},
63
+ "rstok": {"n_tok": 256, "bits": 18, "tile": 256},
64
+ }
65
+
66
+
67
+ def profile_for(tokenizer, width, height, **kw):
68
+ """-> a LinkProfile with the token geometry that `tokenizer` actually requires."""
69
+ if tokenizer not in TOKENIZERS:
70
+ raise ValueError(f"unknown tokenizer {tokenizer!r}; expected one of {sorted(TOKENIZERS)}")
71
+ spec = dict(TOKENIZERS[tokenizer])
72
+ spec.update(kw)
73
+ return LinkProfile(width, height, **spec)
74
+
75
+
76
+ def _sniff(weights):
77
+ """-> "opd" | "rstok", read off the checkpoint's own config.
78
+
79
+ Sniffing rather than making the caller declare it: the two checkpoints are structurally
80
+ distinct, and a mismatched declaration would fail deep inside a state-dict load with an
81
+ unhelpful message."""
82
+ import torch
83
+ cfg = torch.load(weights, map_location="cpu", weights_only=False).get("config", {})
84
+ if "token_grid" in cfg or "quantizer" in cfg:
85
+ return "rstok"
86
+ if "model" in cfg:
87
+ return "opd"
88
+ raise ValueError(f"cannot tell which tokenizer {weights} holds")
89
 
90
 
91
  # ---------------------------------------------------------------------------- codes on disk
 
118
  return VQFrame(torch.tensor(j["codes"], dtype=torch.long), prof)
119
 
120
 
121
+ def frame_from_wire(wire, width=None, height=None, n_tok=48, tile=256, bits=12, profile=None):
122
  """-> VQFrame, from packed downlink bytes.
123
 
124
+ Nothing in the stream says how big the frame is or how it was tokenized: frame size, tile size,
125
+ token count and codebook width are deployment constants both ends already agree on, so
126
+ spending downlink bytes on a header that always says the same thing is pure overhead. The
127
+ caller therefore supplies the geometry.
128
+
129
+ Pass `profile=` when you already have one (from the encoder, or from load_codes) -- it carries
130
+ all four constants together. The loose width/height form defaults to One-D-Piece's geometry,
131
+ which is wrong for the RS-Tokenizer, and getting only n_tok right while leaving bits at 12 is
132
+ exactly the mistake that produces a wrong-length frame."""
133
  import torch
134
+ prof = profile or LinkProfile(width, height, tile=tile, n_tok=n_tok, bits=bits)
135
  return VQFrame(torch.tensor(unpack_frame(prof, wire), dtype=torch.long), prof)
136
 
137
 
 
218
  two runs at different batch sizes are not required to be bit-identical.
219
  """
220
 
221
+ def __init__(self, weights, width, height, tokenizer="auto", n_tok=None, tile=None,
222
+ bits=None, device="auto", dtype="float16"):
223
  import torch
224
+ self.tokenizer = _sniff(weights) if tokenizer == "auto" else tokenizer
225
+ over = {k: v for k, v in (("n_tok", n_tok), ("tile", tile), ("bits", bits))
226
+ if v is not None}
227
+ self.profile = profile_for(self.tokenizer, width, height, **over)
228
  dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
229
+ if self.tokenizer == "rstok":
230
+ from .sat.rstok import RSTokEncoder as E
231
+ else:
232
+ from .sat.encoder import SatEncoder as E
233
+ self.encoder = E(weights, self.profile, device=device, dtype=dt)
234
  self.device, self.dtype = self.encoder.device, dt
235
 
236
  @property
 
274
 
275
  # ---------------------------------------------------------------------------- ground
276
  class Ground:
277
+ """Ground side, pixel reconstruction only. Codes in, image out.
278
 
279
+ bfloat16 by default: measured identical to float32 to 0.01 dB on both backends, at half the
280
+ memory. float16 is NOT the default because the RS-Tokenizer decoder overflows in it -- the
281
+ whole frame comes back NaN, which casts to a black image rather than to an error.
282
+ """
283
+
284
+ def __init__(self, weights, tokenizer="auto", device="auto", dtype="bfloat16"):
285
  import torch
286
  from .device import resolve
287
+ self.tokenizer = _sniff(weights) if tokenizer == "auto" else tokenizer
288
  dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
289
  self.device = resolve(device)
290
+ if self.tokenizer == "rstok":
291
+ from .ground.rstok_ground import RSTokDecoder as D
292
+ else:
293
+ from .ground.decoder import GroundDecoder as D
294
+ self.decoder = D(weights, dtype=dt).to(self.device)
295
 
296
  def decode(self, frame):
297
  """-> float tensor [H, W] in [0, 1], overlaps averaged."""
 
305
  # discarding two thirds of the output.
306
  while x.ndim > 3:
307
  x = x[0]
308
+ x = x.mean(0) if x.ndim == 3 else x
309
+ # A non-finite decode must be an error, not a picture. numpy casts NaN to 0 without
310
+ # complaint, so an overflowed decoder silently produces a plausible-looking all-black PNG
311
+ # that passes every check except a PSNR comparison the caller may not be running.
312
+ if not torch.isfinite(x).all():
313
+ raise RuntimeError(
314
+ f"decoder produced non-finite output ({int((~torch.isfinite(x)).sum())} of "
315
+ f"{x.numel()} values). The RS-Tokenizer decoder overflows in float16 -- "
316
+ f"use dtype='float32' or 'bfloat16'.")
317
+ return x
318
 
319
  def reconstruct(self, frame):
320
  """-> PIL grayscale image."""
 
339
  from .device import resolve
340
  if bool(decoder) == bool(latent_ground):
341
  raise ValueError("give exactly one of decoder= or latent_ground=")
342
+ # Only the pixel path supports the RS-Tokenizer: the latent path projects a PARTIAL decode
343
+ # into the vision tower, through a bridge trained against One-D-Piece's particular
344
+ # intermediate features. Pointing it at another tokenizer would run, and would be wrong.
345
+ self.tokenizer = _sniff(decoder) if decoder else "opd"
346
  self.device = resolve(device)
347
  self.model_dir = model_dir
348
  self.latent = bool(latent_ground)
satlink/bench/test_isolation.py CHANGED
@@ -120,13 +120,23 @@ def main():
120
  else:
121
  print(f"PASS satlink/{m}.py: deps within {sorted(SHARED_DEPS.get(m, set())) or 'none'}")
122
 
123
- # the satellite bundle must not contain decoder weights or decoder classes
124
- enc = open(os.path.join(ROOT, "sat", "opd_encoder.py"), encoding="utf-8").read()
125
- if "class TiTokDecoder" in enc or "PixelDecoder" in enc:
126
- fails += 1
127
- print("FAIL satlink/sat/opd_encoder.py contains decoder classes")
128
- else:
129
- print("PASS satlink/sat/opd_encoder.py: no decoder classes")
 
 
 
 
 
 
 
 
 
 
130
 
131
  for sub in ("sat", "ground"):
132
  deps = set()
 
120
  else:
121
  print(f"PASS satlink/{m}.py: deps within {sorted(SHARED_DEPS.get(m, set())) or 'none'}")
122
 
123
+ # The satellite bundle must not contain decoder classes. Checked per vendored file rather
124
+ # than by scanning the package, because each tokenizer's decoder has its own class name and a
125
+ # generic "*Decoder" grep would both miss names and fire on unrelated ones.
126
+ for fn, banned in (("opd_encoder.py", ("class TiTokDecoder", "PixelDecoder")),
127
+ ("rstok_encoder.py", ("class LlamaTransformerDecoder",))):
128
+ path = os.path.join(ROOT, "sat", fn)
129
+ if not os.path.isfile(path):
130
+ fails += 1
131
+ print(f"FAIL satlink/sat/{fn} is missing")
132
+ continue
133
+ src = open(path, encoding="utf-8").read()
134
+ hit = [b for b in banned if b in src]
135
+ if hit:
136
+ fails += 1
137
+ print(f"FAIL satlink/sat/{fn} contains decoder classes: {hit}")
138
+ else:
139
+ print(f"PASS satlink/sat/{fn}: no decoder classes")
140
 
141
  for sub in ("sat", "ground"):
142
  deps = set()
satlink/bench/test_rstok.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RS-Tokenizer backend: does the vendored split reproduce the published reference?
2
+
3
+ The source repo (NJU-Star/RS-Tokenizer-10M-100x-0701) ships its own encode/decode outputs for
4
+ eight crops. That is the only thing worth testing here -- the model code was vendored verbatim, so
5
+ what can break is the SPLIT: wrong weight partition, a dropped tensor, the normalisation applied
6
+ on one side and not the other.
7
+
8
+ python -m satlink.bench.test_rstok --ref /path/to/RS-Tokenizer-10M-100x-0701
9
+
10
+ Without --ref the reference-dependent checks are skipped and reported as skipped, not as passes.
11
+
12
+ Note on precision: the published token grids were produced in bf16. In bf16 this implementation
13
+ reproduces them EXACTLY. In fp32 about 14% of indices differ -- a 196560-entry codebook in 24
14
+ dimensions has very dense nearest neighbours, so the argmin flips readily -- while the resulting
15
+ image moves by ~0.2/255. That is why the code check is pinned to bf16 and the pixel check is not.
16
+ """
17
+ import argparse
18
+ import glob
19
+ import os
20
+ import sys
21
+
22
+ import torch
23
+
24
+ from ..frame import VQFrame
25
+ from ..protocol import LinkProfile
26
+
27
+ N_TOK, BITS = 256, 18
28
+
29
+
30
+ def _psnr(a, b):
31
+ m = ((a - b) ** 2).mean()
32
+ return float("inf") if m <= 0 else float(10 * torch.log10(255.0 ** 2 / m))
33
+
34
+
35
+ def main():
36
+ ap = argparse.ArgumentParser()
37
+ ap.add_argument("--ref", default=None, help="clone of the source repo, for reference outputs")
38
+ ap.add_argument("--sat", default="satlink/weights/rstok_sat.pt")
39
+ ap.add_argument("--ground", default="satlink/weights/rstok_ground.pt")
40
+ ap.add_argument("--device", default="auto")
41
+ a = ap.parse_args()
42
+ from ..device import resolve
43
+ dev = resolve(a.device)
44
+ fails = []
45
+
46
+ # ---------------------------------------------------------------- profile
47
+ p = LinkProfile(1024, 768, n_tok=N_TOK, bits=BITS)
48
+ ok = p.crop_bytes == 576 and p.n_tiles == 12
49
+ print(f" {'PASS' if ok else 'FAIL'} profile: {p.n_tok}x{p.bits}b -> {p.crop_bytes} B/crop, "
50
+ f"{p.n_tiles} crops, ratio {p.width * p.height / p.frame_bytes:.1f}x")
51
+ fails += [] if ok else ["profile"]
52
+
53
+ # 256 x 18 = 4608 bits is a whole number of bytes, so the pack must not add padding.
54
+ from ..protocol import pack_indices, unpack_indices
55
+ idx = [(i * 7919) % 196560 for i in range(N_TOK)]
56
+ w = pack_indices(idx, BITS)
57
+ ok = len(w) == 576 and unpack_indices(w, N_TOK, BITS) == idx
58
+ print(f" {'PASS' if ok else 'FAIL'} 18-bit packing round-trips with no padding ({len(w)} B)")
59
+ fails += [] if ok else ["packing"]
60
+
61
+ # An index at the top of the codebook must survive; one past the 18-bit ceiling must not.
62
+ try:
63
+ pack_indices([196559], BITS)
64
+ pack_indices([1 << BITS], BITS)
65
+ print(" FAIL out-of-range index accepted")
66
+ fails.append("range")
67
+ except ValueError:
68
+ print(" PASS codebook bounds enforced (196559 fits, 2^18 rejected)")
69
+
70
+ # ---------------------------------------------------------------- weights
71
+ from ..sat.rstok import RSTokEncoder
72
+ sat = RSTokEncoder(a.sat, LinkProfile(256, 256, n_tok=N_TOK, bits=BITS),
73
+ device=dev, dtype=torch.bfloat16)
74
+ n_sat = sum(x.numel() for x in sat.parameters())
75
+ ok = 13e6 < n_sat < 15e6
76
+ print(f" {'PASS' if ok else 'FAIL'} satellite half is {n_sat/1e6:.1f}M params (encoder+VQ only)")
77
+ fails += [] if ok else ["sat-size"]
78
+
79
+ # The satellite file must not carry decoder weights -- that is the whole point of the split.
80
+ sd = torch.load(a.sat, map_location="cpu", weights_only=False)["state_dict"]
81
+ stray = [k for k in sd if k.startswith(("decoder.", "post_quant_embed."))]
82
+ print(f" {'PASS' if not stray else 'FAIL'} satellite checkpoint holds no decoder tensors"
83
+ + (f" (found {stray[:3]})" if stray else ""))
84
+ fails += [] if not stray else ["sat-leak"]
85
+
86
+ # n_tok is not a rate knob for this tokenizer; truncating must be refused, not silently done.
87
+ try:
88
+ RSTokEncoder(a.sat, LinkProfile(256, 256, n_tok=128, bits=BITS), device=dev)
89
+ print(" FAIL truncated n_tok accepted (these tokens are a grid, not a prefix)")
90
+ fails.append("truncate")
91
+ except ValueError:
92
+ print(" PASS truncated n_tok refused")
93
+
94
+ # ---------------------------------------------------------------- reference
95
+ if not a.ref:
96
+ print(" SKIP reference checks: pass --ref <clone of the source repo>")
97
+ else:
98
+ import numpy as np
99
+ from PIL import Image
100
+ from ..ground.rstok_ground import RSTokDecoder
101
+ base = os.path.join(a.ref, "outputs_center_crop_no_enhance_check")
102
+ ins = sorted(glob.glob(os.path.join(base, "encoded", "input", "*_input.png")))
103
+ if not ins:
104
+ print(f" FAIL no reference inputs under {base}")
105
+ fails.append("ref-missing")
106
+ else:
107
+ dec = RSTokDecoder(a.ground).to(dev)
108
+ prof = LinkProfile(256, 256, n_tok=N_TOK, bits=BITS)
109
+ agree, dpix = [], []
110
+ for f in ins:
111
+ stem = os.path.basename(f)[:-len("_input.png")]
112
+ im = Image.open(f).convert("RGB")
113
+ ref = np.load(os.path.join(base, "encoded", "tokens",
114
+ f"{stem}_tokens.npy")).astype(np.int64).reshape(-1)
115
+ mine = sat.encode(im).tokens[0].numpy()
116
+ agree.append((mine == ref).mean() * 100)
117
+ rr = torch.tensor(np.asarray(Image.open(
118
+ os.path.join(base, "decoded", "recon", f"{stem}_recon.png")
119
+ ).convert("L")).astype("float32"))
120
+ got = dec(VQFrame(torch.tensor(ref).view(1, -1), prof).to(dev))[0].cpu() * 255
121
+ dpix.append(float((rr - got).abs().mean()))
122
+ m = float(np.mean(agree))
123
+ ok = m == 100.0
124
+ print(f" {'PASS' if ok else 'FAIL'} bf16 codes match the published grids exactly "
125
+ f"({m:.2f}% over {len(ins)} crops)")
126
+ fails += [] if ok else ["ref-codes"]
127
+ # Decoding the REFERENCE indices must reproduce the reference image; this isolates the
128
+ # decoder split from any encoder drift.
129
+ worst = max(dpix)
130
+ ok = worst < 3.0
131
+ print(f" {'PASS' if ok else 'FAIL'} reference indices decode to the reference image "
132
+ f"(worst {worst:.2f}/255, mean {sum(dpix)/len(dpix):.2f})")
133
+ fails += [] if ok else ["ref-pixels"]
134
+
135
+ # ---------------------------------------------------------------- precision
136
+ # bf16 is not safe for this tokenizer and the test says so in numbers rather than in a comment.
137
+ if a.ref:
138
+ import numpy as np
139
+ from PIL import Image
140
+ from ..ground.rstok_ground import RSTokDecoder
141
+ base = os.path.join(a.ref, "outputs_center_crop_no_enhance_check")
142
+ ins = sorted(glob.glob(os.path.join(base, "encoded", "input", "*_input.png")))
143
+ if ins:
144
+ dec = locals().get("dec") or RSTokDecoder(a.ground).to(dev)
145
+ prof = LinkProfile(256, 256, n_tok=N_TOK, bits=BITS)
146
+ worst = {}
147
+ for dt, nm in ((torch.bfloat16, "bf16"), (torch.float16, "fp16")):
148
+ e = RSTokEncoder(a.sat, prof, device=dev, dtype=dt)
149
+ lo = 1e9
150
+ for f in ins:
151
+ im = Image.open(f).convert("RGB")
152
+ src = torch.tensor(np.asarray(im.convert("L")).astype("float32"))
153
+ r = dec(e.encode(im).to(dev))[0].cpu() * 255
154
+ lo = min(lo, _psnr(src, r))
155
+ worst[nm] = lo
156
+ ok = worst["fp16"] > 35.0
157
+ print(f" {'PASS' if ok else 'FAIL'} fp16 holds up (worst crop "
158
+ f"{worst['fp16']:.1f} dB); bf16 worst is {worst['bf16']:.1f} dB "
159
+ f"-- bf16 is unsafe for a 196560-entry codebook, use fp16")
160
+ fails += [] if ok else ["fp16-quality"]
161
+
162
+ print()
163
+ print("OK" if not fails else f"{len(fails)} FAILURE(S): {fails}")
164
+ return 0 if not fails else 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
satlink/examples/quickstart.py CHANGED
@@ -42,8 +42,10 @@ def main():
42
  print(f" 落盘 {codes}")
43
 
44
  # ---------------------------------------------------------------- 链路
45
- # 下行只有码字。帧尺寸不在流里,是两端约定的常量,所以这里要显式给出。
46
- frame = satlink.frame_from_wire(wire, w, h, n_tok=frame.profile.n_tok)
 
 
47
 
48
  # ---------------------------------------------------------------- 地面
49
  recon = os.path.join(a.out, "recon.png")
 
42
  print(f" 落盘 {codes}")
43
 
44
  # ---------------------------------------------------------------- 链路
45
+ # 下行只有码字。帧尺寸和 token 几何都不在流里,是两端约定的常量,所以这里要显式给出。
46
+ # 这里直接把 profile 传过去,它把四个常量一起带上——只对齐 n_tok 而漏掉 bits,
47
+ # 正是会解出错误长度的那种失误。
48
+ frame = satlink.frame_from_wire(wire, profile=frame.profile)
49
 
50
  # ---------------------------------------------------------------- 地面
51
  recon = os.path.join(a.out, "recon.png")
satlink/ground/modeling_vqvlm.py CHANGED
@@ -31,6 +31,23 @@ import torch.nn.functional as F
31
 
32
  from ..protocol import LinkProfile
33
  from .decoder import GroundDecoder
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  from ..frame import VQFrame
35
  from .glm4v_text import Glm4vTextModel
36
  from .glm4v_vision import Glm4vVisionModel
@@ -66,7 +83,7 @@ class VQGlm4vForConditionalGeneration(nn.Module):
66
  # The tokenizer decoder is a submodule, not a neighbour. It is created here so that
67
  # .to(), .eval(), state_dict() and autocast all cover it -- the ways a bolt-on module
68
  # silently diverges from the model it feeds.
69
- self.opd = GroundDecoder(decoder_weights, profile) if decoder_weights else None
70
  self.register_buffer("_img_mean", torch.tensor(IMAGE_MEAN).view(3, 1, 1), persistent=False)
71
  self.register_buffer("_img_std", torch.tensor(IMAGE_STD).view(3, 1, 1), persistent=False)
72
 
 
31
 
32
  from ..protocol import LinkProfile
33
  from .decoder import GroundDecoder
34
+
35
+
36
+ def _build_decoder(weights, profile):
37
+ """-> the ground tokenizer matching this checkpoint.
38
+
39
+ Two backends are supported and they are told apart by the checkpoint's own config rather than
40
+ by a flag the caller has to keep in sync: One-D-Piece (48 tokens x 12 bits per crop) and the
41
+ RS-Tokenizer (256 x 18). Both expose the same forward(VQFrame) -> [3, H, W], so everything
42
+ downstream -- patchify, the vision tower, the LLM -- is unchanged. Only the pixel path is
43
+ wired up for the RS-Tokenizer; the latent path needs a bridge trained against its particular
44
+ partial decode, and no such bridge exists."""
45
+ import torch as _torch
46
+ cfg = _torch.load(weights, map_location="cpu", weights_only=False).get("config", {})
47
+ if "token_grid" in cfg or "quantizer" in cfg:
48
+ from .rstok_ground import RSTokDecoder
49
+ return RSTokDecoder(weights, profile)
50
+ return GroundDecoder(weights, profile)
51
  from ..frame import VQFrame
52
  from .glm4v_text import Glm4vTextModel
53
  from .glm4v_vision import Glm4vVisionModel
 
83
  # The tokenizer decoder is a submodule, not a neighbour. It is created here so that
84
  # .to(), .eval(), state_dict() and autocast all cover it -- the ways a bolt-on module
85
  # silently diverges from the model it feeds.
86
+ self.opd = _build_decoder(decoder_weights, profile) if decoder_weights else None
87
  self.register_buffer("_img_mean", torch.tensor(IMAGE_MEAN).view(3, 1, 1), persistent=False)
88
  self.register_buffer("_img_std", torch.tensor(IMAGE_STD).view(3, 1, 1), persistent=False)
89
 
satlink/ground/rstok_decoder.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RS-Tokenizer (NJU-Star/RS-Tokenizer-10M-100x-0701) -- DECODER + codebook.
2
+
3
+ The ground half of satlink/sat/rstok_encoder.py. Vendored from the same infer.py: the shared
4
+ transformer blocks, LlamaTransformerDecoder, and the VectorQuantizer (needed only for its
5
+ codebook lookup -- the ground never runs an argmin).
6
+
7
+ 336M parameters against the encoder's 13M, which is the whole reason the split is worth making.
8
+
9
+ VENDORING EDITS: same as the satellite file -- unused third-party imports dropped, encoder class
10
+ removed. The maths is untouched.
11
+ """
12
+ import math
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+
18
+
19
+ def build_2d_rope_cache(grid_h, grid_w, head_dim, theta=10000.0, device=None, dtype=torch.float32):
20
+ if head_dim % 4 != 0:
21
+ raise ValueError(f"head_dim must be divisible by 4, got {head_dim}")
22
+ dim_half = head_dim // 2
23
+ dim_quarter = head_dim // 4
24
+ inv_freq = 1.0 / (
25
+ theta ** (
26
+ torch.arange(0, dim_quarter, device=device, dtype=torch.float32) * 2 / dim_half
27
+ )
28
+ )
29
+ h_pos = torch.arange(grid_h, device=device, dtype=torch.float32)
30
+ w_pos = torch.arange(grid_w, device=device, dtype=torch.float32)
31
+ angles_h = torch.outer(h_pos, inv_freq)
32
+ angles_w = torch.outer(w_pos, inv_freq)
33
+ angles_h = angles_h[:, None, :].expand(grid_h, grid_w, dim_quarter).reshape(-1, dim_quarter)
34
+ angles_w = angles_w[None, :, :].expand(grid_h, grid_w, dim_quarter).reshape(-1, dim_quarter)
35
+ emb_h = torch.cat([angles_h, angles_h], dim=-1)
36
+ emb_w = torch.cat([angles_w, angles_w], dim=-1)
37
+ cos = torch.cat([emb_h.cos(), emb_w.cos()], dim=-1)
38
+ sin = torch.cat([emb_h.sin(), emb_w.sin()], dim=-1)
39
+ return cos.to(dtype=dtype), sin.to(dtype=dtype)
40
+
41
+
42
+ def rotate_half(x):
43
+ x1, x2 = x.chunk(2, dim=-1)
44
+ return torch.cat([-x2, x1], dim=-1)
45
+
46
+
47
+ def rotate_half_2d(x):
48
+ dim = x.shape[-1]
49
+ return torch.cat([
50
+ rotate_half(x[..., : dim // 2]),
51
+ rotate_half(x[..., dim // 2:]),
52
+ ], dim=-1)
53
+
54
+
55
+ def apply_2d_rope(q, k, cos, sin):
56
+ cos = cos[None, None, :, :].to(q.dtype)
57
+ sin = sin[None, None, :, :].to(q.dtype)
58
+ return q * cos + rotate_half_2d(q) * sin, k * cos + rotate_half_2d(k) * sin
59
+
60
+
61
+ class RMSNorm(nn.Module):
62
+ def __init__(self, dim, eps=1e-6):
63
+ super().__init__()
64
+ self.eps = eps
65
+ self.weight = nn.Parameter(torch.ones(dim))
66
+
67
+ def forward(self, x):
68
+ x_float = x.float()
69
+ normed = x_float * torch.rsqrt(x_float.pow(2).mean(-1, keepdim=True) + self.eps)
70
+ return normed.to(dtype=x.dtype) * self.weight.to(dtype=x.dtype)
71
+
72
+
73
+ class SwiGLUMLP(nn.Module):
74
+ def __init__(self, dim, mlp_ratio=4.0):
75
+ super().__init__()
76
+ raw_hidden = int(dim * mlp_ratio * 2 / 3)
77
+ hidden = (raw_hidden + 63) // 64 * 64
78
+ self.gate_proj = nn.Linear(dim, hidden, bias=False)
79
+ self.up_proj = nn.Linear(dim, hidden, bias=False)
80
+ self.down_proj = nn.Linear(hidden, dim, bias=False)
81
+
82
+ def forward(self, x):
83
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
84
+
85
+
86
+ class LlamaSelfAttention(nn.Module):
87
+ def __init__(self, dim, num_heads):
88
+ super().__init__()
89
+ if dim % num_heads != 0:
90
+ raise ValueError(f"dim {dim} must be divisible by heads {num_heads}")
91
+ self.num_heads = int(num_heads)
92
+ self.head_dim = int(dim) // int(num_heads)
93
+ self.dim = int(dim)
94
+ self.q_proj = nn.Linear(dim, dim, bias=False)
95
+ self.k_proj = nn.Linear(dim, dim, bias=False)
96
+ self.v_proj = nn.Linear(dim, dim, bias=False)
97
+ self.out_proj = nn.Linear(dim, dim, bias=False)
98
+
99
+ def forward(self, x, cos, sin):
100
+ batch, seqlen, _ = x.shape
101
+ q = self.q_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
102
+ k = self.k_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
103
+ v = self.v_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
104
+ q, k = apply_2d_rope(q, k, cos, sin)
105
+ out = F.scaled_dot_product_attention(q, k, v, dropout_p=0.0, is_causal=False)
106
+ out = out.transpose(1, 2).reshape(batch, seqlen, self.dim)
107
+ return self.out_proj(out)
108
+
109
+
110
+ class LlamaViTBlock(nn.Module):
111
+ def __init__(self, dim, num_heads, mlp_ratio=4.0):
112
+ super().__init__()
113
+ self.norm1 = RMSNorm(dim)
114
+ self.attn = LlamaSelfAttention(dim, num_heads)
115
+ self.norm2 = RMSNorm(dim)
116
+ self.mlp = SwiGLUMLP(dim, mlp_ratio)
117
+
118
+ def forward(self, x, cos, sin):
119
+ x = x + self.attn(self.norm1(x), cos, sin)
120
+ x = x + self.mlp(self.norm2(x))
121
+ return x
122
+
123
+
124
+ class LlamaTransformerDecoder(nn.Module):
125
+ def __init__(
126
+ self,
127
+ image_size=256,
128
+ patch_size=16,
129
+ width=1024,
130
+ layers=24,
131
+ heads=16,
132
+ mlp_ratio=4,
133
+ drop_rate=0.0,
134
+ use_ffn_output=True,
135
+ dim_ffn_output=None,
136
+ **_,
137
+ ):
138
+ super().__init__()
139
+ del image_size, drop_rate
140
+ self.patch_size = int(patch_size)
141
+ self.width = int(width)
142
+ self.head_dim = self.width // int(heads)
143
+ self.rope_theta = 10000.0
144
+ self.blocks = nn.ModuleList([
145
+ LlamaViTBlock(self.width, heads, mlp_ratio) for _ in range(int(layers))
146
+ ])
147
+ self.norm = RMSNorm(self.width)
148
+ self.use_ffn_output = bool(use_ffn_output)
149
+ if dim_ffn_output is None:
150
+ dim_ffn_output = self.width * 4
151
+ if self.use_ffn_output:
152
+ self.ffn = nn.Sequential(nn.Linear(self.width, int(dim_ffn_output)), nn.Tanh())
153
+ conv_in_features = int(dim_ffn_output)
154
+ else:
155
+ self.ffn = nn.Identity()
156
+ conv_in_features = self.width
157
+ self.conv_out = nn.Linear(conv_in_features, 3 * self.patch_size * self.patch_size, bias=True)
158
+
159
+ def forward(self, x, grid_sizes):
160
+ if len(set(tuple(item) for item in grid_sizes)) != 1:
161
+ raise ValueError("Standalone decoder expects a uniform batch.")
162
+ gh, gw = tuple(int(v) for v in grid_sizes[0])
163
+ cos, sin = build_2d_rope_cache(
164
+ gh,
165
+ gw,
166
+ self.head_dim,
167
+ self.rope_theta,
168
+ device=x.device,
169
+ dtype=x.dtype,
170
+ )
171
+ for block in self.blocks:
172
+ x = block(x, cos, sin)
173
+ x = self.norm(x)
174
+ x = self.ffn(x)
175
+ x = self.conv_out(x)
176
+ batch = x.shape[0]
177
+ x = x.reshape(batch, gh, gw, 3, self.patch_size, self.patch_size)
178
+ x = x.permute(0, 3, 1, 4, 2, 5).reshape(batch, 3, gh * self.patch_size, gw * self.patch_size)
179
+ return x
180
+
181
+
182
+ class VectorQuantizer(nn.Module):
183
+ def __init__(self, n_embed=196560, embed_dim=24, l2_norm=True, beta=0.25):
184
+ super().__init__()
185
+ self.n_embed = int(n_embed)
186
+ self.embed_dim = int(embed_dim)
187
+ self.l2_norm = bool(l2_norm)
188
+ self.beta = float(beta)
189
+ self.embedding = nn.Embedding(self.n_embed, self.embed_dim)
190
+ self.bits_per_index = int(math.ceil(math.log2(self.n_embed)))
191
+
192
+ def encode_indices(self, z):
193
+ if self.l2_norm:
194
+ z = F.normalize(z, dim=-1)
195
+ z_flat = z.reshape(-1, self.embed_dim)
196
+ embedding_weight = F.normalize(self.embedding.weight, dim=-1)
197
+ distance = -z_flat @ embedding_weight.t()
198
+ else:
199
+ z_flat = z.reshape(-1, self.embed_dim)
200
+ distance = (
201
+ torch.sum(z_flat ** 2, dim=1, keepdim=True)
202
+ + torch.sum(self.embedding.weight ** 2, dim=1)
203
+ - 2 * z_flat @ self.embedding.weight.t()
204
+ )
205
+ indices = torch.argmin(distance.detach(), dim=1)
206
+ return indices.view(z.shape[0], -1)
207
+
208
+ def decode_indices(self, indices):
209
+ z_q = self.embedding(indices.long())
210
+ if self.l2_norm:
211
+ z_q = F.normalize(z_q, dim=-1)
212
+ return z_q
213
+
214
+ def forward(self, z):
215
+ indices = self.encode_indices(z)
216
+ z_q = self.embedding(indices).view_as(z)
217
+ if self.l2_norm:
218
+ z_q = F.normalize(z_q, dim=-1)
219
+ loss = self.beta * torch.mean(((z_q.detach() - z) ** 2).sum(dim=-1))
220
+ z_q = z + (z_q - z).detach()
221
+ return z_q, loss, {"indices": indices}
satlink/ground/rstok_ground.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ground side, RS-Tokenizer backend. Same interface as satlink.ground.decoder.GroundDecoder.
2
+
3
+ dec = RSTokDecoder("satlink/weights/rstok_ground.pt")
4
+ image = dec(frame) # [3, H, W] in [0,1]
5
+
6
+ 340.9M parameters against the satellite's 13.9M -- the asymmetry the split exists to exploit.
7
+
8
+ Like the One-D-Piece decoder this is an nn.Module, not a preprocessing script, so it can be owned
9
+ by the VLM as a submodule rather than bolted on beside it.
10
+
11
+ Deliberately no reversible-preprocess inverse here. The published pipeline offers a logit
12
+ unsharp/affine enhancement whose inverse needs a per-crop `center` scalar, and the zero-header
13
+ protocol has nowhere to put per-crop side information. Running the inverse WITHOUT that scalar
14
+ would be worse than not running it at all -- it would apply the wrong affine and shift the whole
15
+ crop -- so the encoder does not apply the forward transform either, and the two halves stay
16
+ consistent. See the note in satlink/tools/export_rstok_weights.py for what that costs.
17
+ """
18
+ import torch
19
+ import torch.nn as nn
20
+
21
+ from ..config import Cfg
22
+ from ..frame import VQFrame
23
+ from ..protocol import LinkProfile
24
+
25
+
26
+ class RSTokDecoder(nn.Module):
27
+ """RS-Tokenizer decoder + codebook."""
28
+
29
+ def __init__(self, weights, profile: LinkProfile = None, dtype=torch.float32, n_layers=None):
30
+ super().__init__()
31
+ from .rstok_decoder import LlamaTransformerDecoder, VectorQuantizer
32
+ ck = torch.load(weights, map_location="cpu", weights_only=False)
33
+ cfg = Cfg(ck["config"])
34
+ self.profile = profile
35
+ self.patch_size, self.image_size = cfg.patch_size, cfg.image_size
36
+ self.grid = tuple(cfg.token_grid)
37
+ q, d = cfg.quantizer, cfg.decoder
38
+ self.quantize = VectorQuantizer(n_embed=q.n_embed, embed_dim=q.embed_dim,
39
+ l2_norm=q.l2_norm)
40
+ self.post_quant_embed = nn.Linear(q.embed_dim, d.width)
41
+ self.decoder = LlamaTransformerDecoder(patch_size=cfg.patch_size, width=d.width,
42
+ layers=d.layers, heads=d.heads,
43
+ mlp_ratio=d.mlp_ratio)
44
+ got = self.load_state_dict(ck["state_dict"], strict=False)
45
+ if got.missing_keys or got.unexpected_keys:
46
+ raise RuntimeError(f"checkpoint mismatch: missing={got.missing_keys[:5]} "
47
+ f"unexpected={got.unexpected_keys[:5]}")
48
+ if n_layers is not None:
49
+ # Truncating costs quality and is only meaningful for experiments; the full stack is
50
+ # the deployment configuration.
51
+ self.decoder.blocks = self.decoder.blocks[:n_layers]
52
+ if dtype == torch.float16:
53
+ # Measured, not defensive: at 1024 width over 24 layers this decoder's activations
54
+ # leave float16's range and the whole frame comes back NaN (which then casts to a
55
+ # black image). bfloat16 has the exponent for it and matches float32 to 0.01 dB.
56
+ raise ValueError("RSTokDecoder overflows in float16; use float32 or bfloat16")
57
+ self.zero_mean = bool(cfg.get("zero_mean", True))
58
+ self.eval()
59
+ for p in self.parameters():
60
+ p.requires_grad_(False)
61
+ self.to(dtype)
62
+
63
+ def codebook_device(self):
64
+ return self.quantize.embedding.weight.device
65
+
66
+ # ------------------------------------------------------------------ tiles
67
+ @torch.no_grad()
68
+ def decode_tiles(self, indices, chunk=8):
69
+ """[n_tiles, 256] int -> [n_tiles, 3, 256, 256] float in [0,1].
70
+
71
+ Chunked because the decoder is 341M parameters at 1024 width over 256 tokens; a whole
72
+ survey frame's worth of crops at once is a needless memory spike when the crops are
73
+ independent anyway."""
74
+ dev = self.codebook_device()
75
+ dt = self.post_quant_embed.weight.dtype
76
+ gh, gw = self.grid
77
+ outs = []
78
+ for i in range(0, indices.shape[0], chunk):
79
+ idx = indices[i:i + chunk].to(dev).long()
80
+ z = self.quantize.decode_indices(idx).to(dt)
81
+ h = self.post_quant_embed(z)
82
+ x = self.decoder(h, grid_sizes=[(gh, gw)] * idx.shape[0]).clamp(-1.0, 1.0)
83
+ outs.append(self._denormalize(x.float()))
84
+ return torch.cat(outs)
85
+
86
+ def _denormalize(self, x):
87
+ """model output -> [0,1]."""
88
+ return ((x + 1.0) / 2.0 if self.zero_mean else x).clamp(0, 1)
89
+
90
+ # ------------------------------------------------------------------ stitch
91
+ @torch.no_grad()
92
+ def stitch(self, tiles, profile):
93
+ """[n_tiles, 3, tile, tile] -> [3, H, W], averaging the overlap.
94
+
95
+ Averaging rather than last-writer-wins: crops are coded independently, so the seam where
96
+ two reconstructions meet is the most visible artifact of the scheme.
97
+
98
+ Channels are collapsed by mean and replicated, as in the One-D-Piece decoder. The source
99
+ imagery is strictly grayscale (the published reference inputs have zero channel spread)
100
+ while the decoder's three outputs disagree by 3-11/255 -- they are three noisy estimates of
101
+ one luminance, so averaging them is a denoiser, not a loss of colour."""
102
+ p = profile
103
+ t = p.tile
104
+ xs, ys = p.starts(0), p.starts(1)
105
+ cw, ch = max(p.width, max(xs) + t), max(p.height, max(ys) + t)
106
+ acc = torch.zeros(ch, cw, dtype=torch.float32, device=tiles.device)
107
+ cnt = torch.zeros(ch, cw, dtype=torch.float32, device=tiles.device)
108
+ k = 0
109
+ for y in ys:
110
+ for x in xs:
111
+ acc[y:y + t, x:x + t] += tiles[k].float().mean(0)
112
+ cnt[y:y + t, x:x + t] += 1.0
113
+ k += 1
114
+ out = (acc / cnt.clamp(min=1)).clamp(0, 1)[:p.height, :p.width]
115
+ return out[None].expand(3, -1, -1).contiguous()
116
+
117
+ # ------------------------------------------------------------------ api
118
+ @torch.no_grad()
119
+ def forward(self, frame: VQFrame):
120
+ """VQFrame -> [3, H, W]. Geometry comes from the frame, so one instance serves any size."""
121
+ return self.stitch(self.decode_tiles(frame.tokens), frame.profile)
satlink/sat/rstok.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Satellite side, RS-Tokenizer backend. Same interface as satlink.sat.encoder.SatEncoder.
2
+
3
+ prof = LinkProfile(1024, 768, n_tok=256, bits=18) # agreed, not transmitted
4
+ sat = RSTokEncoder("satlink/weights/rstok_sat.pt", prof)
5
+ frame = sat.encode(image)
6
+
7
+ Two things differ from the One-D-Piece backend and both are visible in the profile rather than
8
+ hidden in the model:
9
+
10
+ n_tok=256, bits=18 a 16x16 grid into a 196560-entry codebook -> 576 B/crop, ~114x
11
+ RGB input One-D-Piece was trained on grayscale and this one was not, so the frame is
12
+ fed as 3 channels. A grayscale source is replicated across them, which is
13
+ what the published reference does too (it opens every input as RGB).
14
+
15
+ DO NOT RUN THIS BACKEND IN bfloat16. The codebook has 196560 entries in 24 dimensions, all L2
16
+ normalised, so neighbouring codewords are separated by very small cosine gaps; bf16's 8-bit
17
+ mantissa is not enough to resolve them and the argmin lands on the wrong codeword often enough to
18
+ destroy a crop. Measured over the eight published reference crops: seven are unaffected, and one
19
+ drops from 41.4 dB (fp16) to 22.5 dB (bf16) -- a 19 dB collapse on 1 image in 8, not a gentle
20
+ average degradation. fp16 tracks fp32 within 0.5 dB everywhere, so fp16 is the default and is what
21
+ the acceptance runs use. (The upstream reference outputs were generated in bf16 and therefore
22
+ carry this collapse; that is why satlink/bench/test_rstok.py pins its exact-match check to bf16 --
23
+ matching the reference is a provenance check, not an endorsement of the dtype.)
24
+
25
+ n_tok is NOT a rate knob here. One-D-Piece is trained with tail-token drop, so any prefix of its
26
+ 48 tokens is a valid encoding; this tokenizer's 256 tokens are a 2-D grid with no importance
27
+ ordering, and dropping any of them removes a patch of the image rather than some detail. So n_tok
28
+ is fixed at 256 and the constructor refuses anything else, instead of silently returning a frame
29
+ the decoder cannot use.
30
+ """
31
+ import torch
32
+
33
+ from ..config import Cfg
34
+ from ..device import autocast, resolve
35
+ from ..frame import VQFrame
36
+ from ..protocol import LinkProfile, pack_frame
37
+ from .rstok_encoder import LlamaTransformerEncoder, VectorQuantizer
38
+
39
+ N_TOK = 256 # 16x16 grid
40
+ BITS = 18 # ceil(log2(196560)); entropy floor is 17.585, so 0.42 bits/index is spent
41
+
42
+
43
+ class RSTokEncoder(torch.nn.Module):
44
+ """RS-Tokenizer encoder + VQ. 13.9M parameters; the 340.9M decoder stays on the ground."""
45
+
46
+ def __init__(self, weights, profile: LinkProfile, device="auto", dtype=torch.float16):
47
+ super().__init__()
48
+ ck = torch.load(weights, map_location="cpu", weights_only=False)
49
+ cfg = Cfg(ck["config"])
50
+ if profile.n_tok != N_TOK or profile.bits != BITS:
51
+ raise ValueError(f"RS-Tokenizer needs n_tok={N_TOK}, bits={BITS}; profile says "
52
+ f"n_tok={profile.n_tok}, bits={profile.bits}. Unlike One-D-Piece "
53
+ f"these tokens are a spatial grid, not an importance-ordered "
54
+ f"sequence, so they cannot be truncated.")
55
+ if profile.tile != cfg.image_size:
56
+ raise ValueError(f"tile {profile.tile} but the tokenizer is trained at "
57
+ f"{cfg.image_size}")
58
+ self.profile = profile
59
+ e = cfg.encoder
60
+ self.encoder = LlamaTransformerEncoder(image_size=cfg.image_size,
61
+ patch_size=cfg.patch_size, width=e.width,
62
+ layers=e.layers, heads=e.heads,
63
+ mlp_ratio=e.mlp_ratio)
64
+ q = cfg.quantizer
65
+ self.quant_embed = torch.nn.Linear(e.width, q.embed_dim)
66
+ self.quantize = VectorQuantizer(n_embed=q.n_embed, embed_dim=q.embed_dim,
67
+ l2_norm=q.l2_norm)
68
+ got = self.load_state_dict(ck["state_dict"], strict=False)
69
+ if got.missing_keys or got.unexpected_keys:
70
+ raise RuntimeError(f"checkpoint mismatch: missing={got.missing_keys[:5]} "
71
+ f"unexpected={got.unexpected_keys[:5]}")
72
+ self.zero_mean = bool(cfg.get("zero_mean", True))
73
+ device = resolve(device)
74
+ self.eval().to(device=device)
75
+ self.device, self.dtype = device, dtype
76
+ for p in self.parameters():
77
+ p.requires_grad_(False)
78
+
79
+ # ---------------------------------------------------------------- tiling
80
+ def tiles(self, img):
81
+ """-> [n_tiles, 3, tile, tile] in profile raster order, values in [0,1]."""
82
+ from .encoder import _to_tensor
83
+ p = self.profile
84
+ x = img if torch.is_tensor(img) else _to_tensor(img)
85
+ x = x.to(self.device, non_blocking=True)
86
+ if x.ndim == 2:
87
+ x = x[None]
88
+ if x.shape[0] == 1:
89
+ x = x.expand(3, -1, -1)
90
+ elif x.shape[0] != 3:
91
+ raise ValueError(f"expected 1 or 3 channels, got {x.shape[0]}")
92
+ _, H, W = x.shape
93
+ if (W, H) != (p.width, p.height):
94
+ raise ValueError(f"frame is {W}x{H}, profile says {p.width}x{p.height}")
95
+ t = p.tile
96
+ if H < t or W < t:
97
+ x = torch.nn.functional.pad(x[None], (0, max(0, t - W), 0, max(0, t - H)),
98
+ mode="replicate")[0]
99
+ xs, ys = p.starts(0), p.starts(1)
100
+ return torch.stack([x[:, y:y + t, x0:x0 + t] for y in ys for x0 in xs]).contiguous()
101
+
102
+ def _normalize(self, x01):
103
+ """[0,1] -> what the model was trained on. zero_mean maps to [-1,1]."""
104
+ return x01 * 2.0 - 1.0 if self.zero_mean else x01
105
+
106
+ # ---------------------------------------------------------------- encode
107
+ @torch.no_grad()
108
+ def indices_batch(self, crops, chunk=64):
109
+ """-> LongTensor [B, 256] for crops already cut: [B, 3, 256, 256] in [0,1]."""
110
+ t = self.profile.tile
111
+ if crops.ndim != 4 or crops.shape[1] != 3 or crops.shape[-2:] != (t, t):
112
+ raise ValueError(f"expected [B, 3, {t}, {t}] crops, got {tuple(crops.shape)}")
113
+ outs = []
114
+ for i in range(0, crops.shape[0], chunk):
115
+ b = self._normalize(crops[i:i + chunk].to(self.device, non_blocking=True))
116
+ with autocast(self.device, self.dtype):
117
+ h, _ = self.encoder(b)
118
+ h = self.quant_embed(h)
119
+ idx = self.quantize.encode_indices(h)
120
+ outs.append(idx.reshape(b.shape[0], -1))
121
+ return torch.cat(outs)
122
+
123
+ @torch.no_grad()
124
+ def indices(self, img, chunk=64):
125
+ return self.indices_batch(self.tiles(img), chunk=chunk)
126
+
127
+ @torch.no_grad()
128
+ def encode(self, img, chunk=64):
129
+ return VQFrame(self.indices(img, chunk=chunk).cpu(), self.profile)
130
+
131
+ @torch.no_grad()
132
+ def encode_wire(self, img, chunk=64):
133
+ return self.pack(self.encode(img, chunk=chunk))
134
+
135
+ def pack(self, frame: VQFrame):
136
+ if frame.profile.crop_bytes != self.profile.crop_bytes:
137
+ raise ValueError("frame profile does not match this encoder's profile")
138
+ return pack_frame(frame.profile, frame.tolist())
satlink/sat/rstok_encoder.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RS-Tokenizer (NJU-Star/RS-Tokenizer-10M-100x-0701) -- ENCODER + VQ only.
2
+
3
+ Vendored from that repo's infer.py, truncated so this file physically cannot contain a decoder:
4
+ the shared transformer blocks, LlamaTransformerEncoder and VectorQuantizer are here;
5
+ LlamaTransformerDecoder is not, and lives in satlink/ground/rstok_decoder.py. Same discipline as
6
+ opd_encoder.py -- an import-path check would still pass while dragging 336M parameters into orbit,
7
+ so the decoder is simply absent from the file.
8
+
9
+ Shape of the thing: a 256x256 crop becomes a 16x16 grid of indices into a 196560-entry codebook,
10
+ so 256 tokens x 18 bits = 576 bytes per crop, about 114x smaller than the 65536 raw grayscale
11
+ bytes. That is 8x more downlink than One-D-Piece's 72 bytes, bought with roughly 20 dB of
12
+ reconstruction PSNR (~41 dB against ~21 dB on the same crops).
13
+
14
+ 196560 is the kissing number of the Leech lattice and embed_dim is 24 -- this is a 24-dimensional
15
+ lattice quantiser, which is why the codebook is not a power of two. 18 bits per index therefore
16
+ wastes 0.42 bits against the 17.585-bit entropy floor; packing at 18 is still exact at the crop
17
+ boundary (256 x 18 = 4608 bits = 576 bytes) so no padding is spent.
18
+
19
+ VENDORING EDITS, all mechanical:
20
+ - numpy / omegaconf / PIL / safetensors / torchvision imports dropped; unused here
21
+ - the decoder class and the combined StandaloneL24SQTokenizer removed
22
+ Nothing in the maths is touched; satlink/bench/test_rstok.py checks the codes against the
23
+ reference token grids published in the source repo.
24
+ """
25
+ import math
26
+
27
+ import torch
28
+ import torch.nn as nn
29
+ import torch.nn.functional as F
30
+
31
+
32
+ def build_2d_rope_cache(grid_h, grid_w, head_dim, theta=10000.0, device=None, dtype=torch.float32):
33
+ if head_dim % 4 != 0:
34
+ raise ValueError(f"head_dim must be divisible by 4, got {head_dim}")
35
+ dim_half = head_dim // 2
36
+ dim_quarter = head_dim // 4
37
+ inv_freq = 1.0 / (
38
+ theta ** (
39
+ torch.arange(0, dim_quarter, device=device, dtype=torch.float32) * 2 / dim_half
40
+ )
41
+ )
42
+ h_pos = torch.arange(grid_h, device=device, dtype=torch.float32)
43
+ w_pos = torch.arange(grid_w, device=device, dtype=torch.float32)
44
+ angles_h = torch.outer(h_pos, inv_freq)
45
+ angles_w = torch.outer(w_pos, inv_freq)
46
+ angles_h = angles_h[:, None, :].expand(grid_h, grid_w, dim_quarter).reshape(-1, dim_quarter)
47
+ angles_w = angles_w[None, :, :].expand(grid_h, grid_w, dim_quarter).reshape(-1, dim_quarter)
48
+ emb_h = torch.cat([angles_h, angles_h], dim=-1)
49
+ emb_w = torch.cat([angles_w, angles_w], dim=-1)
50
+ cos = torch.cat([emb_h.cos(), emb_w.cos()], dim=-1)
51
+ sin = torch.cat([emb_h.sin(), emb_w.sin()], dim=-1)
52
+ return cos.to(dtype=dtype), sin.to(dtype=dtype)
53
+
54
+
55
+ def rotate_half(x):
56
+ x1, x2 = x.chunk(2, dim=-1)
57
+ return torch.cat([-x2, x1], dim=-1)
58
+
59
+
60
+ def rotate_half_2d(x):
61
+ dim = x.shape[-1]
62
+ return torch.cat([
63
+ rotate_half(x[..., : dim // 2]),
64
+ rotate_half(x[..., dim // 2:]),
65
+ ], dim=-1)
66
+
67
+
68
+ def apply_2d_rope(q, k, cos, sin):
69
+ cos = cos[None, None, :, :].to(q.dtype)
70
+ sin = sin[None, None, :, :].to(q.dtype)
71
+ return q * cos + rotate_half_2d(q) * sin, k * cos + rotate_half_2d(k) * sin
72
+
73
+
74
+ class RMSNorm(nn.Module):
75
+ def __init__(self, dim, eps=1e-6):
76
+ super().__init__()
77
+ self.eps = eps
78
+ self.weight = nn.Parameter(torch.ones(dim))
79
+
80
+ def forward(self, x):
81
+ x_float = x.float()
82
+ normed = x_float * torch.rsqrt(x_float.pow(2).mean(-1, keepdim=True) + self.eps)
83
+ return normed.to(dtype=x.dtype) * self.weight.to(dtype=x.dtype)
84
+
85
+
86
+ class SwiGLUMLP(nn.Module):
87
+ def __init__(self, dim, mlp_ratio=4.0):
88
+ super().__init__()
89
+ raw_hidden = int(dim * mlp_ratio * 2 / 3)
90
+ hidden = (raw_hidden + 63) // 64 * 64
91
+ self.gate_proj = nn.Linear(dim, hidden, bias=False)
92
+ self.up_proj = nn.Linear(dim, hidden, bias=False)
93
+ self.down_proj = nn.Linear(hidden, dim, bias=False)
94
+
95
+ def forward(self, x):
96
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
97
+
98
+
99
+ class LlamaSelfAttention(nn.Module):
100
+ def __init__(self, dim, num_heads):
101
+ super().__init__()
102
+ if dim % num_heads != 0:
103
+ raise ValueError(f"dim {dim} must be divisible by heads {num_heads}")
104
+ self.num_heads = int(num_heads)
105
+ self.head_dim = int(dim) // int(num_heads)
106
+ self.dim = int(dim)
107
+ self.q_proj = nn.Linear(dim, dim, bias=False)
108
+ self.k_proj = nn.Linear(dim, dim, bias=False)
109
+ self.v_proj = nn.Linear(dim, dim, bias=False)
110
+ self.out_proj = nn.Linear(dim, dim, bias=False)
111
+
112
+ def forward(self, x, cos, sin):
113
+ batch, seqlen, _ = x.shape
114
+ q = self.q_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
115
+ k = self.k_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
116
+ v = self.v_proj(x).reshape(batch, seqlen, self.num_heads, self.head_dim).transpose(1, 2)
117
+ q, k = apply_2d_rope(q, k, cos, sin)
118
+ out = F.scaled_dot_product_attention(q, k, v, dropout_p=0.0, is_causal=False)
119
+ out = out.transpose(1, 2).reshape(batch, seqlen, self.dim)
120
+ return self.out_proj(out)
121
+
122
+
123
+ class LlamaViTBlock(nn.Module):
124
+ def __init__(self, dim, num_heads, mlp_ratio=4.0):
125
+ super().__init__()
126
+ self.norm1 = RMSNorm(dim)
127
+ self.attn = LlamaSelfAttention(dim, num_heads)
128
+ self.norm2 = RMSNorm(dim)
129
+ self.mlp = SwiGLUMLP(dim, mlp_ratio)
130
+
131
+ def forward(self, x, cos, sin):
132
+ x = x + self.attn(self.norm1(x), cos, sin)
133
+ x = x + self.mlp(self.norm2(x))
134
+ return x
135
+
136
+
137
+ class LlamaTransformerEncoder(nn.Module):
138
+ def __init__(self, image_size=256, patch_size=16, width=384, layers=5, heads=6, mlp_ratio=4, drop_rate=0.0, **_):
139
+ super().__init__()
140
+ del drop_rate
141
+ self.image_size = int(image_size)
142
+ self.patch_size = int(patch_size)
143
+ self.width = int(width)
144
+ self.head_dim = self.width // int(heads)
145
+ self.rope_theta = 10000.0
146
+ self.patch_embed = nn.Conv2d(3, self.width, kernel_size=self.patch_size, stride=self.patch_size, bias=True)
147
+ self.blocks = nn.ModuleList([
148
+ LlamaViTBlock(self.width, heads, mlp_ratio) for _ in range(int(layers))
149
+ ])
150
+ self.norm = RMSNorm(self.width)
151
+
152
+ def forward(self, x):
153
+ feat = self.patch_embed(x)
154
+ batch, channels, gh, gw = feat.shape
155
+ tokens = feat.flatten(2).transpose(1, 2)
156
+ cos, sin = build_2d_rope_cache(
157
+ gh,
158
+ gw,
159
+ self.head_dim,
160
+ self.rope_theta,
161
+ device=tokens.device,
162
+ dtype=tokens.dtype,
163
+ )
164
+ for block in self.blocks:
165
+ tokens = block(tokens, cos, sin)
166
+ return self.norm(tokens), [(gh, gw)] * batch
167
+
168
+
169
+ class VectorQuantizer(nn.Module):
170
+ def __init__(self, n_embed=196560, embed_dim=24, l2_norm=True, beta=0.25):
171
+ super().__init__()
172
+ self.n_embed = int(n_embed)
173
+ self.embed_dim = int(embed_dim)
174
+ self.l2_norm = bool(l2_norm)
175
+ self.beta = float(beta)
176
+ self.embedding = nn.Embedding(self.n_embed, self.embed_dim)
177
+ self.bits_per_index = int(math.ceil(math.log2(self.n_embed)))
178
+
179
+ def encode_indices(self, z):
180
+ if self.l2_norm:
181
+ z = F.normalize(z, dim=-1)
182
+ z_flat = z.reshape(-1, self.embed_dim)
183
+ embedding_weight = F.normalize(self.embedding.weight, dim=-1)
184
+ distance = -z_flat @ embedding_weight.t()
185
+ else:
186
+ z_flat = z.reshape(-1, self.embed_dim)
187
+ distance = (
188
+ torch.sum(z_flat ** 2, dim=1, keepdim=True)
189
+ + torch.sum(self.embedding.weight ** 2, dim=1)
190
+ - 2 * z_flat @ self.embedding.weight.t()
191
+ )
192
+ indices = torch.argmin(distance.detach(), dim=1)
193
+ return indices.view(z.shape[0], -1)
194
+
195
+ def decode_indices(self, indices):
196
+ z_q = self.embedding(indices.long())
197
+ if self.l2_norm:
198
+ z_q = F.normalize(z_q, dim=-1)
199
+ return z_q
200
+
201
+ def forward(self, z):
202
+ indices = self.encode_indices(z)
203
+ z_q = self.embedding(indices).view_as(z)
204
+ if self.l2_norm:
205
+ z_q = F.normalize(z_q, dim=-1)
206
+ loss = self.beta * torch.mean(((z_q.detach() - z) ** 2).sum(dim=-1))
207
+ z_q = z + (z_q - z).detach()
208
+ return z_q, loss, {"indices": indices}
satlink/tools/export_rstok_weights.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert the published RS-Tokenizer safetensors into the two .pt files satlink loads.
2
+
3
+ python -m satlink.tools.export_rstok_weights --src /tmp/rstok --out satlink/weights
4
+
5
+ Why convert at all: satlink/sat is held to a torch-only dependency budget (checked by
6
+ satlink/bench/test_isolation.py), and reading .safetensors would add safetensors to the flight
7
+ bundle for one load call. The ground has no such constraint but is converted too, so both halves
8
+ load the same way.
9
+
10
+ The split mirrors the source repo's own: encoder + quant_embed + codebook go to the satellite,
11
+ post_quant_embed + decoder + codebook go to the ground. The codebook appears on BOTH sides on
12
+ purpose -- the satellite needs it to run the argmin, the ground needs it to look indices back up,
13
+ and it is 196560 x 24 fp32 = 18.9 MB, small next to either half.
14
+ """
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ import torch
20
+
21
+ SAT_PREFIXES = ("encoder.", "quant_embed.", "quantize.")
22
+ GROUND_PREFIXES = ("post_quant_embed.", "decoder.", "quantize.")
23
+
24
+ # decoder.distill_mlp.* is a 25.2M-parameter auxiliary head used during training only. The
25
+ # published inference script silently drops it (it lands in load_partition's skipped_unexpected
26
+ # bucket), so it is dropped HERE instead -- at export, once, visibly -- rather than by relaxing
27
+ # the runtime's state-dict check, which would then also swallow a genuine mismatch.
28
+ DROP_PREFIXES = ("decoder.distill_mlp.",)
29
+
30
+ # From the source repo's config.yaml. Recorded in the checkpoint so the runtime does not need the
31
+ # yaml (and so omegaconf stays out of the deployment bundle).
32
+ CONFIG = {"image_size": 256, "patch_size": 16, "token_grid": [16, 16],
33
+ "encoder": {"width": 384, "layers": 5, "heads": 6, "mlp_ratio": 4},
34
+ "decoder": {"width": 1024, "layers": 24, "heads": 16, "mlp_ratio": 4},
35
+ "quantizer": {"n_embed": 196560, "embed_dim": 24, "l2_norm": True},
36
+ "zero_mean": True,
37
+ # The user's deployment choice: no reversible preprocess. The enhanced variant needs a
38
+ # per-crop `center` scalar as side information, which the zero-header protocol has no
39
+ # place for; without it the average cost is 2.5 dB, though one of the eight published
40
+ # reference crops drops 42.5 -> 22.7 dB, so this is a tail risk, not a uniform 2.5 dB.
41
+ "reversible_preprocess": "none"}
42
+
43
+
44
+ def main():
45
+ ap = argparse.ArgumentParser()
46
+ ap.add_argument("--src", required=True, help="a clone of NJU-Star/RS-Tokenizer-10M-100x-0701")
47
+ ap.add_argument("--out", default="satlink/weights")
48
+ a = ap.parse_args()
49
+ from safetensors.torch import load_file
50
+
51
+ enc = load_file(os.path.join(a.src, "encoder_vq.safetensors"), device="cpu")
52
+ dec = load_file(os.path.join(a.src, "decoder.safetensors"), device="cpu")
53
+ full = dict(enc)
54
+ full.update(dec)
55
+ os.makedirs(a.out, exist_ok=True)
56
+
57
+ for name, prefixes in (("rstok_sat.pt", SAT_PREFIXES), ("rstok_ground.pt", GROUND_PREFIXES)):
58
+ sd = {k: v for k, v in full.items()
59
+ if k.startswith(prefixes) and not k.startswith(DROP_PREFIXES)}
60
+ if not sd:
61
+ raise RuntimeError(f"no tensors matched {prefixes}")
62
+ p = os.path.join(a.out, name)
63
+ torch.save({"config": CONFIG, "state_dict": sd}, p)
64
+ n = sum(v.numel() for v in sd.values())
65
+ print(f" {p} {len(sd)} tensors, {n/1e6:.1f}M params, {os.path.getsize(p)/1e6:.1f} MB")
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())