RS: extreme image tokenization for satelliteβground vision-language inference
Two models for the same problem β get a remote-sensing image from orbit to a VLM on the ground over a link that cannot carry pixels.
Both build on RSThinker (GLM-4.1V-9B + Geo-CoT) and a
One-D-Piece tokenizer fine-tuned on grayscale remote-sensing imagery. RSThinker itself is not
redistributed here β point model_name_or_path at the upstream weights.
72 bytes / 256Γ256 crop
βββββββββββββ βββββββββββββββββββββΊ ββββββββββββββββββββββββ
β satellite β 48 Γ 12-bit codes β ground β
β 5.92 M β β decoder + GLM-4.1V β
βββββββββββββ ββββββββββββββββββββββββ
satlink_pixel/ β the pixel path (deployed, benchmarked)
The satellite runs encoder + VQ only (5.92 M params). The ground decodes the codes back to pixels inside the VLM and answers.
Wire format. Everything fixed at deployment β frame size, tile size, tokens per crop, codebook width β is a configured constant, not a transmitted field. Tile count per frame is then constant too, so the tile index and the frame boundary are implied by position in the stream: there is no header. 48 tokens Γ log2(4096) bits = 72 bytes per 256Γ256 crop, 910Γ smaller than the raw grayscale it stands for, 0% protocol overhead.
n_tok is a rate knob. One-D-Piece is trained with tail-token drop, so tokens are ordered by
importance (measured entropy 10.2β10.9 bits at positions 0β7, 11.1β11.7 at 40β47) and any prefix
is a valid encoding. Cutting it trades detail for bandwidth linearly, at inference time.
The decoder is a submodule of the VLM, not a preprocessing step. Codes β latents β pixels β patches β vision tower β LLM happens in one forward, one checkpoint, one graph.
from satlink.protocol import LinkProfile, unpack_frame
from satlink.sat.encoder import SatEncoder
from satlink.frame import VQFrame
from satlink.ground.engine.llm import LLM
prof = LinkProfile(512, 512) # agreed by both ends, never transmitted
sat = SatEncoder("satlink/weights/sat_encoder.pt", prof, device="cuda")
frame = sat.encode(image) # structured: a VQFrame
wire = sat.encode_wire(image) # packed: 288 bytes for a 512Γ512 frame
llm = LLM(model="<RSThinker>", profile=prof, decoder="satlink/weights/ground_decoder.pt")
out = llm.generate([prompt_ids], [params], frames=[frame])
satlink/ground/infer.py is the reference inference script (cache-free, deliberately simple).
satlink/ground/engine/ is a vendored nano-vLLM
(MIT) with paged KV, CUDA graphs and continuous batching.
Measured
| throughput | |
|---|---|
| satellite encode (A100, fp16, bs 64) | 5 518 crop/s β 512Γ512 frames at 1 379 fps, 3.2 Mb/s downlink |
| ground decode (A100, batch 8) | 224 tok/s |
VRSBench, identical items, [email protected] / accuracy:
| bench | reconstruct-then-serve baseline | satlink full path | McNemar p |
|---|---|---|---|
| referring (n=1000) | 43.3% | 44.0% | 0.470 |
| VQA (n=996) | 54.7% | 54.2% | 0.725 |
The split costs nothing measurable. On original (uncompressed) images the same model scores 79.5% on referring β the ~35-point gap is the 48-token bottleneck, not the satellite/ground split.
Verified, not asserted
A reimplementation that loads and produces plausible output is worth nothing; a mis-ported rope
degrades quality without erroring. satlink/bench/ checks:
| test | result |
|---|---|
| satellite codes vs monolithic pipeline | bit-identical |
| ground reconstruction vs monolithic | 98.5β99.5% pixels identical, max 1/255 |
| vision tower vs transformers | 2.56e-05 relative |
| language model vs transformers (incl. M-RoPE) | 1.51e-06 relative |
whole stack vs HF generate |
32/32 tokens identical |
| engine vs cache-free reference (eager & CUDA-graph) | 48/48 tokens identical |
| deployment isolation | satlink/sat imports torch only |
test_isolation.py is what makes "the halves are decoupled" checkable rather than aspirational:
the satellite package cannot import decoder code, and satlink/sat/opd_encoder.py is truncated
before TiTokDecoder so it physically cannot.
latent_sft_dec12/ β the latent path (trained and benchmarked)
Skips pixel reconstruction entirely. The One-D-Piece decoder is cut off at layer 6, where the representation is already a 16x16 grid per crop, and projected straight into the vision tower's patch-embedding space. The satellite bundle is byte-identical between the two paths β both send the same codes; only the ground tokenizer differs, and it shrinks from 337.8M to 76.2M.
codes β 6 of 24 decoder layers (frozen) β cross-crop interaction + projection (TRAINED)
β GLM-4.1V vision tower (frozen) β LLM (LoRA)
The hard part is not the projection. A frame is many crops; they overlap by an amount that depends
on the frame size; their origins are not multiples of the 16-pixel feature cell; and the vision
tower wants ONE patch grid at 14 px/patch in a merge-block-major order that is not raster. The
bridge resamples each crop's feature grid at the target patch centres and averages where crops
overlap. test_bridge_geometry.py checks placement, order and coordinates: 0 misplacements over
10 892 patches across five frame sizes including overlapping and non-square.
Cross-crop information travels two ways: attention over the assembled grid, and a whole-image context vector modulating the scale and shift of both norms in every block.
Trained: 149.5M of 10.8B (interaction 50.4M + projection 3.9M + LoRA 95.2M). Frozen: encoder, VQ, the decoder layers, the entire vision tower. 1 epoch over GeoCoT380k, loss 1.095 β 0.817.
Measured β same items, same engine, same scorer
| referring mIoU / [email protected] / [email protected] |
VQA | LHRS | |
|---|---|---|---|
| base @ reconstruction | 39.1 / 43.3 / 20.4 | 58.5 | 52.6 |
| satlink pixel path | 39.1 / 44.0 / 20.8 | 57.7 | 50.1 |
| latent, 6 layers, 1 epoch | 35.5 / 39.8 / 12.6 | 62.8 | β |
| latent, 12 layers, 3 epochs | 37.8 / 40.5 / 13.3 | 64.4 | 48.3 |
(n = 1000 / 996 / 690. base on original images scores 79.5 referring and 69.4 LHRS β the gap to any row above is the 48-token bottleneck itself, not the satellite/ground split, which costs nothing measurable: pixel path vs base is +0.7, p = 0.47.)
| latent 12L/3ep vs pixel | Ξ | significance |
|---|---|---|
| referring [email protected] | β3.5 | p = 0.026 |
| referring mIoU | β1.26 | CI [β3.01, +0.42] β not significant |
| referring [email protected] | β7.5 | β |
| VQA | +6.6 | p = 3.6e-05 |
| LHRS | β1.9 | p = 0.40 |
Not "worse" or "better" β coarse versus fine. Going from 6 to 12 decoder layers (with 3x the training) closed the mIoU gap to noise: the boxes are as good on average. [email protected] barely moved (12.6 β 13.3 against the pixel path's 20.8). That is a resolution ceiling: the bridge upsamples a 16x16 per-crop feature grid to a 36x36 patch grid, and upsampling does not create detail. More decoder depth improves feature quality, not spatial resolution. VQA and LHRS, which do not need sub-patch precision, are unaffected or better.
The comparison is not matched. The latent models had SFT on GeoCoT380k; the pixel path is the base model run zero-shot on reconstructions. "Latent is better at VQA" and "latent was trained more" are not separated by these runs. The missing control is an SFT'd pixel path.
The +32.5 on VQA colour questions is almost certainly not colour perception. The tokenizer is grayscale β both paths get the same codes and neither carries colour. SFT most likely fitted this dataset's answer distribution for colour questions.
Scoring
Open-ended VQA is scored by bench_score.vqa_ok, not string equality. Strict equality marked
aerial wrong against aerial view on ~4% of rows of every path; naive substring matching is
worse, scoring 1 correct for 10 and No correct for north-south. The rule is: numbers must
match numerically and exactly; otherwise one side's whole content words must be a subset of the
other's. Correcting it moved all three paths by ~+3.2 and left their ordering unchanged.
Precomputing codes
The encoder is frozen, so encoding per epoch is waste. precompute_codes.py does it once:
60 GB of JPEG-in-parquet β 1.9 GB of codes, a 210 GB datasets Arrow cache goes with it, and
LLaMA-Factory's ~23-minute "Converting format of dataset" pass becomes seconds. Stores the full
256 tokens per crop; training slices the first 48, so n_tok stays free.
Note: VQ argmin flips ~3% of codes under different batch shapes (4096 entries, many near-ties),
so codes are not bit-reproducible across batch configurations. Feeding identical codes through
both paths agrees to 1.0e-06, so this is quantiser jitter, not a plumbing difference.
Using it
The halves run as separate programs and talk through the wire format. That is the deployment shape, not a demo convention, and it is why each half can be validated alone.
# SATELLITE -- 5.9M params; imports satlink.sat + satlink.protocol and nothing else
python -m satlink.examples.sat_encode \
--image satlink/assets/grid3x3_600x600.png --out codes.json
# 600x600 -> 3x3 crops (84 px overlap) -> 648 bytes, 556x vs raw grayscale, 411 ms
# GROUND -- never imports satellite code; a codes file is enough
python -m satlink.examples.ground_answer --wire codes.json --model <RSThinker> \
--latent-ground satlink/weights/latent_ground_dec12.pt --lora latent_sft_dec12 \
--question "where is the ship"
--out codes.json writes the codes as readable text; any other suffix writes the packed bytes a
radio would carry. Swap --latent-ground/--lora for --decoder satlink/weights/ground_decoder.pt
to run the pixel path on the same codes β the satellite bundle is identical for both.
Tests
Split the same way as the code, so neither half needs the other:
python -m satlink.bench.test_protocol # shared contract; no torch, no weights (~1 s)
python -m satlink.bench.test_sat # satellite only; --no-model for geometry alone
python -m satlink.bench.test_ground --decoder ... --latent-ground ... --model ...
python -m satlink.bench.test_isolation # neither package can reach outside itself
Five images ship in satlink/assets/ covering the cases that break tiling β exact multiples of the
tile size (zero overlap), 84 px and 75 px overlap, non-square, and a single crop β with
reference.json recording their codes, reconstructions, and a reference answer.
What is judged, and what is only reported. Codes are not required to match bitwise: the VQ step is an argmin over 4096 near-tied entries, and fp16 flips ~0.5% of them against an fp32 reference. Measured, that costs 0.47/255 mean pixel difference against a reconstruction error of 10.25/255, and the differences sit on high-frequency edges where moving an edge by a fraction of a pixel swings one pixel by 159/255 without changing anything semantic. So:
| criterion | reported only | |
|---|---|---|
| reconstruction | mean |Ξ| < 2/255 | max |Ξ|, fraction above 32/255 |
| LLM, teacher-forced | mean |Ξlogprob| < 0.05, top-1 β₯ 80% | β |
| LLM, free generation | non-empty, no degenerate repetition, </think> closes |
common-prefix length |
Free generation is reported, not judged, on purpose: greedy decoding turns any near-tie into a different trajectory, so two healthy devices can diverge at token 43 and never re-converge. (One of the five bundled images does exactly that, at 98.4% teacher-forced top-1 agreement.) Teacher forcing is the stable measurement β one forward pass, no decoding, a number that moves smoothly with numerical drift.
Batch shape does not affect codes: fp32 at chunk 8 and chunk 64 agree bitwise, with zero pixel difference. Precision does.
The heavier parity suite (test_vision_parity, test_text_parity, test_stack_parity,
test_engine_parity) needs the 10B checkpoint and several minutes.
Layout
satlink_pixel/satlink/
protocol.py wire contract, zero dependencies
frame.py VQFrame β the structured form, shared by both ends
sat/ satellite package β torch only, no decoder code
ground/ decoder + GLM-4.1V + vendored nano-vLLM engine
weights/ sat_encoder.pt (23 MB) Β· ground_decoder.pt (1.3 GB, pixel path)
latent_ground.pt (589 MB, 6 layers) Β·
latent_ground_dec12.pt (891 MB, 12 layers -- the better latent model)
ground/latent_bridge.py Β· modeling_latentvlm.py the latent path
examples/ sat_encode.py Β· ground_answer.py -- the halves, run separately
assets/ five test images + reference.json (codes, reconstructions, answers)
bench/ throughput, isolation, and the parity suite
eval/ eval_satlink.py serves both paths (--latent-ground), 8-way shardable
latent_sft/ 6 decoder layers, 1 epoch
latent_sft_dec12/ 12 decoder layers, 3 epochs -- use this one
adapter_model.safetensors Β· opd_bridge.pt Β· code/
Licence
Apache-2.0. Vendored One-D-Piece (Apache-2.0, ByteDance) and nano-vLLM (MIT) keep their notices.