File size: 7,429 Bytes
0eb0c3a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """Record a reference encoding + reconstruction of the bundled images, for the stability tests.
What is being pinned is the PICTURE, not the codes. Codes are an argmin over 4096 near-tied
codebook entries; fp16 flips about 0.5% of them against fp32. Measured, that costs 0.47/255 mean
absolute pixel difference -- against a reconstruction error of 10.25/255 -- and the differences sit
on high-frequency edges, where a code flip moves an edge by a fraction of a pixel and a single
pixel can swing 159/255 without anything semantic changing. So the reference stores both, and the
tests judge on mean pixel difference.
JSON plus PNG, no binary blobs: a reference is something a human should be able to open and diff
when a new device disagrees.
python -m satlink.tools.make_reference
"""
import argparse
import glob
import hashlib
import json
import os
import sys
import torch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from satlink.frame import VQFrame # noqa: E402
from satlink.protocol import LinkProfile # noqa: E402
from satlink.sat.encoder import SatEncoder # noqa: E402
def _pixel_grid(model, prof):
f = model.patch_size * model.merge
return (max(f, round(prof.height / f) * f) // model.patch_size,
max(f, round(prof.width / f) * f) // model.patch_size)
def _teacher_forced_logprobs(model, frame, ids, pos, answer, device):
"""-> the logprob the model assigns to each answer token, given the prompt and all earlier
answer tokens. One forward pass, deterministic, no decoding."""
import torch
full = torch.cat([ids, torch.tensor(answer, device=device)])
nxt = int(pos.max()) + 1
tail = torch.arange(nxt, nxt + len(answer), device=device).expand(3, -1)
fpos = torch.cat([pos, tail], dim=1)
with torch.no_grad(), model.reference_attention():
h = model(full, fpos, frame=frame)
logits = model.compute_logits(h[len(ids) - 1:-1]).float()
lp = torch.log_softmax(logits, -1)
return lp[torch.arange(len(answer)), torch.tensor(answer, device=device)].tolist()
def _record_answers(ref, a):
"""Record, per image: the generated answer, and the logprob the model assigns to each of its
tokens under teacher forcing.
The test uses BOTH. Teacher forcing is the stable measurement -- one forward pass, no decoding,
a continuous number that moves smoothly with numerical drift. Free generation is the one that
tells you what a user would actually see, but it is brittle by nature: greedy decoding turns
any near-tie into a different trajectory, so it is reported as a common-prefix length rather
than an equality check.
"""
import torch
from tokenizers import Tokenizer
from satlink.frame import VQFrame
from satlink.ground.infer import greedy
from satlink.ground.modeling_vqvlm import mrope_positions
if a.latent_ground:
from satlink.ground.modeling_latentvlm import LatentGlm4vForConditionalGeneration as M
model = M.from_pretrained(a.model, a.latent_ground, lora=a.lora, device=a.device)
tail, path = "<|assistant|>", "latent"
else:
from satlink.ground.modeling_vqvlm import VQGlm4vForConditionalGeneration as M
model = M.from_pretrained(a.model, a.decoder, LinkProfile(512, 512), device=a.device)
tail, path = "<|assistant|>\n", "pixel"
tok = Tokenizer.from_file(os.path.join(a.model, "tokenizer.json"))
IMG = model.image_token_id
ref["answers"] = {"path": path, "question": a.question, "items": {}}
for name, meta in sorted(ref["images"].items()):
prof = LinkProfile(meta["width"], meta["height"], n_tok=a.n_tok)
frame = VQFrame(torch.tensor(meta["codes"], dtype=torch.long), prof).to(a.device)
n_img = model.n_image_tokens(prof) if a.latent_ground else model.n_image_tokens()
gh, gw = model.image_grid(prof) if a.latent_ground else _pixel_grid(model, prof)
ids = torch.tensor(
tok.encode("[gMASK]<sop><|user|>\n<|begin_of_image|>", add_special_tokens=False).ids
+ [IMG] * n_img
+ tok.encode(f"<|end_of_image|>{a.question}{tail}", add_special_tokens=False).ids,
device=a.device)
pos = mrope_positions(ids, IMG, gh, gw, model.merge).to(a.device)
with model.reference_attention():
ans = greedy(model, ids, pos, frame, max_new=a.answer_tokens)
lp = _teacher_forced_logprobs(model, frame, ids, pos, ans, a.device)
ref["answers"]["items"][name] = {
"tokens": ans, "logprobs": [round(x, 4) for x in lp],
"text": tok.decode(ans, skip_special_tokens=False)}
print(f" answer {name:24} {len(ans)} tok, mean logprob {sum(lp)/len(lp):.4f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--assets", default="satlink/assets")
ap.add_argument("--sat", default="satlink/weights/sat_encoder.pt")
ap.add_argument("--decoder", default="satlink/weights/ground_decoder.pt")
ap.add_argument("--n-tok", type=int, default=48)
ap.add_argument("--model", default=None,
help="also record a reference ANSWER and its teacher-forced logprobs")
ap.add_argument("--latent-ground", default=None)
ap.add_argument("--lora", default=None)
ap.add_argument("--question", default="where is the largest building in the image")
ap.add_argument("--answer-tokens", type=int, default=96)
ap.add_argument("--device", default="cuda")
a = ap.parse_args()
from PIL import Image
import torchvision.transforms.functional as TF
from satlink.ground.decoder import GroundDecoder
gnd = GroundDecoder(a.decoder).to(a.device)
ref = {"n_tok": a.n_tok, "dtype": "float32",
"device": torch.cuda.get_device_name(0) if a.device.startswith("cuda") else "cpu",
"note": "codes recorded in fp32; tests compare RECONSTRUCTED PIXELS, not codes",
"images": {}}
os.makedirs(os.path.join(a.assets, "reference"), exist_ok=True)
for path in sorted(glob.glob(os.path.join(a.assets, "*.png"))):
img = Image.open(path).convert("RGB")
prof = LinkProfile(*img.size, n_tok=a.n_tok)
sat = SatEncoder(a.sat, prof, device=a.device, dtype=torch.float32)
codes = sat.encode(img)
with torch.no_grad():
rec = gnd(VQFrame(codes.tokens, prof).to(a.device)).cpu()
name = os.path.basename(path)
rec_path = os.path.join(a.assets, "reference", name)
TF.to_pil_image(rec).save(rec_path, "PNG", optimize=True)
ref["images"][name] = {
"sha256": hashlib.sha256(open(path, "rb").read()).hexdigest()[:16],
"width": img.size[0], "height": img.size[1], "grid": list(prof.grid),
"tile_origins": [prof.tile_origin(i) for i in range(prof.n_tiles)],
"recon": f"reference/{name}",
"codes": codes.tolist(),
}
print(f" {name:26} {img.size[0]}x{img.size[1]:<5} {prof.n_tiles} crops -> "
f"{prof.frame_bytes} B, recon {os.path.getsize(rec_path)/1e3:.0f} KB")
if a.model:
_record_answers(ref, a)
out = os.path.join(a.assets, "reference.json")
json.dump(ref, open(out, "w"), indent=1)
print(f"-> {out} ({os.path.getsize(out)/1e6:.2f} MB, recorded on {ref['device']})")
return 0
if __name__ == "__main__":
sys.exit(main())
|