satlink: add RS-Tokenizer backend (256x18b, ~114x, ~41dB) alongside One-D-Piece; pixel path only
Browse files- satlink/API.md +33 -1
- satlink/accept/run_ground.py +9 -2
- satlink/accept/run_sat.py +26 -8
- satlink/api.py +92 -17
- satlink/bench/test_isolation.py +17 -7
- satlink/bench/test_rstok.py +168 -0
- satlink/examples/quickstart.py +4 -2
- satlink/ground/modeling_vqvlm.py +18 -1
- satlink/ground/rstok_decoder.py +221 -0
- satlink/ground/rstok_ground.py +121 -0
- satlink/sat/rstok.py +138 -0
- satlink/sat/rstok_encoder.py +208 -0
- satlink/tools/export_rstok_weights.py +70 -0
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="
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 <
|
| 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)} 个结果文件可解析、字段完整、码字在
|
| 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 |
-
|
| 84 |
-
|
| 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,
|
| 175 |
-
device="auto", dtype="float16"):
|
| 176 |
import torch
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
import torch
|
| 228 |
from .device import resolve
|
| 229 |
-
|
| 230 |
dt = getattr(torch, dtype) if isinstance(dtype, str) else dtype
|
| 231 |
self.device = resolve(device)
|
| 232 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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())
|