Reimplementing RAU — VLMs Pointing SAM2 at the Right Region, With Real Code

arXiv:2509.22404 — a real Qwen2.5-VL + real SAM2 fusion, trained on real CAMUS cardiac ultrasound, ELI5 to full technical breakdown

papers
computer-vision
segmentation
vision-language-models
open-source
Author

Hasan Goni

Published

August 28, 2026

Modified

October 2, 2026

0.1 The paper

RAU: Reference-based Anatomical Understanding with Vision Language Models — Yiwei Li, Yikang Liu, Jiaqi Guo, Lin Zhao, Zheyuan Zhang, Xiao Chen, Boris Mailhe, Ankush Mukherjee, Terrence Chen, Shanhui Sun (United Imaging Intelligence / University of Georgia / Northwestern, arXiv:2509.22404)

Same standard as last time: full real code below, an architecture diagram traced from the actual transformers source (not guessed), and a real training run — no pseudocode.


0.2 Explain it like I’m five

RAU, sketched out.

Imagine you have exactly one photo of a heart with all the parts labeled in crayon — “this blob is the pump room, this one’s the waiting room.” Now someone hands you a new, unlabeled photo of a different heart and asks “where’s the pump room in this one?”

You don’t need a whole art class to answer that. You just compare the two photos, notice they have roughly the same shape in roughly the same place, point at the matching spot, and say “there — color that one in.” That’s it. That’s the whole paper.

The “pointing” is done by a VLM (a model that can look at pictures and talk about them). The “coloring in neatly” is done by SAM2, a model that’s really good at drawing a clean outline once you’ve pointed at roughly the right spot. Neither one had to have seen this exact heart before.

0.3 Explain it like I’m a data scientist

RAU tackles anatomical structure identification/segmentation in medical images under label scarcity by using one annotated reference image as an in-context spatial prior, instead of training a fully-supervised segmentation model per anatomy/modality.

Three escalating formulations, all on top of Qwen2.5-VL-7B:

  1. VQA labeling — SFT then GRPO (RL), reward = label accuracy + output-format validity. The RL stage matters a lot: the paper’s own CoT-before/after comparison (their Fig. A1) shows the model shifts from judging regions in isolation to explicitly reasoning about relative spatial relationships between reference and target regions — and that shift is what drives OOD generalization, not the SFT stage.
  2. Bbox localization — the VLM emits <Seg> tokens whose embeddings regress boxes via a small MLP; per-image global label assignment is then solved as an optimal transport problem (Sinkhorn) over all predicted boxes at once, using relative spatial position as a soft constraint, rather than assigning each box’s label independently (which hallucinates under dense/ambiguous layouts).
  3. Segmentation via SAM2 fusion (the part we reimplement below) — a <Seg> token’s hidden state is projected through an MLP into SAM2’s memory-embedding space, then retrieved via dot-product attention against SAM2 memory slots built from the reference mask (Eq. 6-7). The fused representation conditions SAM2’s own mask decoder. No explicit point/box prompt is needed — the “prompt” is semantic, not geometric.

Headline result: VLM+SAM2 beats SAM2-Memory and SAM2-Memory-Ref-SFT baselines by a wide margin on both in-distribution sets (Arcade Dice 0.0346→0.6754, RAOS 0.1084→0.7503) and, more interestingly, on two out-of-distribution modalities never trained on (CAMUS ultrasound: 40%→ 95.4% labeling accuracy; LERA bone X-ray: 30%→61.9%). The ablation (their Table 4) isolates why: initializing the SAM2-fusion stage from the RL-tuned VQA checkpoint — not vanilla, not SFT-only — is what makes OOD transfer work. SFT-only overfits to dataset-specific patterns (76% on CAMUS in-domain-style eval vs. only 34% OOD on LERA); RL-driven spatial reasoning generalizes.

0.4 Jargon buster

Term Plain-English meaning
VLM (Vision-Language Model) A model that takes images + text and outputs text — here, Qwen2.5-VL
Reference image The one labeled example the model is shown, used to infer labels on a new, unlabeled image
<Seg> token A special vocabulary entry added to the VLM’s tokenizer; the model is trained to “say” it when it wants to hand off to the segmentation model — the hidden state at that token position carries the semantic query
GRPO Group Relative Policy Optimization — an RL fine-tuning method used in the paper’s second training stage: sample several completions per prompt, score each with a reward, and push the policy toward the above-average ones in the group — no separate learned value/critic network needed. Implemented for real below
SAM2 memory bank / memory attention SAM2’s native mechanism for propagating a mask across video frames using learned memory slots; the paper repurposes it for reference→target propagation instead of frame→frame
target_embedding A real, documented parameter on Sam2Model.forward() for injecting a semantic-concept embedding into the mask decoder (originally built for PerSAM-style personalization) — this is our real integration point for RAU’s fused query
Optimal transport / Sinkhorn An algorithm for assigning items (predicted boxes) to categories (labels) jointly, using a global cost matrix, instead of picking each assignment independently — reduces hallucination when many regions are crowded together
Masked-average pooling Averaging a feature map’s vectors only over the pixels covered by a given mask, producing one summary vector per labeled region
DICE / BCE loss Two standard segmentation losses: Dice rewards mask overlap directly; BCE (binary cross-entropy) is a per-pixel classification loss. RAU (and our demo) trains on a weighted sum of both
KL penalty A term in the GRPO loss that punishes the policy for drifting too far (in distribution) from the frozen reference (SFT) policy — without it, reward-chasing can degenerate the model’s language into something unrecognizable
LoRA (Low-Rank Adaptation) Instead of updating a full weight matrix, freeze it and add a trainable low-rank update (r=8 here) on top — a cheap way to fine-tune a few million parameters instead of billions
OOD (out-of-distribution) Evaluating on a dataset/modality never seen during training — the paper’s strongest result

0.5 Architecture — where the fusion actually plugs into real code

Traced from the real, installed transformers source (Sam2Model.forward, Sam2MaskDecoder, Sam2TwoWayTransformer) — not a schematic guess:

Three-column architecture diagram: Qwen2.5-VL, RAU fusion, SAM2

RAU architecture: Qwen2.5-VL produces a <SEG> hidden state, the fusion block turns it into a query that attends over the reference memory, and the result z is fed to SAM2’s mask decoder through its target_embedding argument.

One real bug this required fixing, not glossed over: Sam2TwoWayTransformer.forward (the transformer inside SAM2’s mask decoder) does queries += target_embedding — an in-place op. That’s fine for SAM2’s original use of target_embedding (PerSAM, inference-only, no gradient needed) but it silently breaks autograd the moment target_embedding itself needs a gradient, which ours does — it’s produced by our trainable projection + attention. Same fix pattern as the SAM3 decoder in last week’s HPMA post: a one-line subclass override, types.MethodType, no fork of transformers needed.


0.6 Setup — one uv-managed environment, all real checkpoints

cd quarto_blog_hasan/notebooks/paper-rau-sam2
uv sync
notebooks/paper-rau-sam2/pyproject.toml
[project]
name = "paper-rau-sam2"
version = "0.1.0"
description = "Real, runnable reimplementation of RAU (Reference-based Anatomical Understanding with Vision Language Models, arXiv:2509.22404) — Qwen2.5-VL emitting a <SEG> query fused into SAM2's mask decoder via its target_embedding interface, trained on real CAMUS echocardiography data."
requires-python = ">=3.11"
dependencies = [
    "torch>=2.4",
    "torchvision>=0.19",
    "transformers>=4.49",
    "accelerate>=0.34",
    "pillow>=10.4",
    "matplotlib>=3.9",
    "huggingface_hub>=0.25",
    "numpy",
    "qwen-vl-utils>=0.0.8",
    "nibabel>=5.4",
]

[tool.uv]
package = false

Substitutions, stated up front, same honesty policy as always:

Paper used We use Why
Qwen2.5-VL-7B Qwen2.5-VL-3B-Instruct Same architecture/API family, downloads and trains in minutes instead of hours here — still a real, unmodified checkpoint
RAOS (413-scan private/challenge CT) + Arcade (angiography) for training CAMUS (public, non-gated, YongchengYAO/CAMUS-Lite on HF) RAOS/Arcade aren’t straightforwardly downloadable; CAMUS is literally the paper’s own OOD test set — using it as our only dataset means every number below is on data the paper itself never trained on
Paper’s LoRA target modules unspecified beyond “the VLM” q_proj, v_proj only (paper’s Table 5 r=8/alpha=16/dropout=0.01 kept exactly) Attention-projection-only LoRA is the standard minimal choice when the paper doesn’t enumerate target modules; keeps the RL phase’s trainable footprint small (~1.8M params) on top of an already-frozen 3.75B backbone

facebook/sam2.1-hiera-large and facebook/dinov2-base are both non-gated. Qwen2.5-VL-3B is non-gated too — nothing here needs a license request, unlike SAM3 last week.

A real data-format gotcha worth flagging: CAMUS-Lite’s dataset card describes “1000 echocardiographic images,” but the actual files are NIfTI half-cardiac-cycle volumes (~22 frames each), not flat PNGs. camus_data.py below handles the real format.

notebooks/paper-rau-sam2/camus_data.py
"""Loading for the real CAMUS-Lite data. Each file is a NIfTI half-cardiac-
cycle sequence (630x519x~22 frames per patient/view), not a flat PNG as the
dataset card's prose suggests — this module handles the real format.

Labels: 1 = left ventricular myocardium, 2 = left ventricle, 3 = left atrium.
"""
from __future__ import annotations

import zipfile
from pathlib import Path

import nibabel as nib
import numpy as np
from huggingface_hub import hf_hub_download
from PIL import Image

LABELS = [1, 2, 3]


def download_camus(cache_dir: Path) -> tuple[Path, Path]:
    images_zip = hf_hub_download("YongchengYAO/CAMUS-Lite", "Images.zip", repo_type="dataset")
    masks_zip = hf_hub_download("YongchengYAO/CAMUS-Lite", "Masks.zip", repo_type="dataset")
    img_dir = cache_dir / "Images"
    mask_dir = cache_dir / "Masks"
    if not img_dir.exists():
        with zipfile.ZipFile(images_zip) as z:
            z.extractall(cache_dir)
    if not mask_dir.exists():
        with zipfile.ZipFile(masks_zip) as z:
            z.extractall(cache_dir)
    return img_dir, mask_dir


def list_patients(img_dir: Path, mask_dir: Path) -> list[tuple[Path, Path, str]]:
    """Returns (image_path, mask_path, patient_view_id) for every patient/view
    that has both an image and a ground-truth volume."""
    out = []
    for img_path in sorted(img_dir.glob("*.nii.gz")):
        stem = img_path.name.replace(".nii.gz", "")
        mask_path = mask_dir / f"{stem}_gt.nii.gz"
        if mask_path.exists():
            out.append((img_path, mask_path, stem))
    return out


def load_frame_pair(image_path: Path, mask_path: Path, ref_frame: int = 0, target_frame: int = -1):
    """Loads two frames (by default first and last) from the same patient's
    half-sequence: a reference frame with its mask, and a target frame with
    its own (held-out at inference, used only for supervision at train time)
    mask."""
    img_vol = nib.load(str(image_path)).get_fdata()
    mask_vol = nib.load(str(mask_path)).get_fdata()
    n_frames = img_vol.shape[-1]
    ref_idx = ref_frame % n_frames
    tgt_idx = target_frame % n_frames

    ref_img = _to_pil(img_vol[..., ref_idx])
    ref_mask = mask_vol[..., ref_idx].astype(np.uint8).T  # match _to_pil's transpose
    tgt_img = _to_pil(img_vol[..., tgt_idx])
    tgt_mask = mask_vol[..., tgt_idx].astype(np.uint8).T
    return ref_img, ref_mask, tgt_img, tgt_mask


def _to_pil(frame_2d: np.ndarray) -> Image.Image:
    arr = frame_2d.astype(np.float32)
    if arr.max() > 0:
        arr = arr / arr.max() * 255.0
    arr = arr.astype(np.uint8).T  # nibabel returns (W, H); transpose to (H, W)
    return Image.fromarray(arr).convert("RGB")

Real check that the loader is actually correct — a genuine reference frame from patient 0001, mask overlaid, anatomically sane (myocardium ring in red, LV cavity in green, LA in blue):

A real CAMUS reference frame with its real ground-truth mask overlaid — confirms the NIfTI orientation-handling is correct before trusting anything built on top of it.

0.7 The fusion mechanism — Eq. 6-7, as real code

notebooks/paper-rau-sam2/rau_modules.py
"""RAU core modules — real reimplementation of the paper's Eq. 6-7 (Section
3.3) plus the DINOv2 reference-retrieval step (Fig. 2a).

Reference-based Anatomical Understanding with Vision Language Models
(arXiv:2509.22404). SAM2 backbone: the paper builds its "memory bank" from
SAM2's own video-native memory encoder, populated from a reference mask. We
build the functional equivalent with SAM2's plain image encoder instead —
masked-average-pooled vision features per labeled region of the retrieved
reference image — and feed the fused query into SAM2's mask decoder through
its public, documented `target_embedding` parameter (the same interface SAM2
exposes for PerSAM-style semantic prompting). Equations 6-7 are implemented
exactly; the memory *construction* substitutes SAM2's video/session memory
machinery for a simpler, non-video masked-pooling step.
"""
from __future__ import annotations

from dataclasses import dataclass, field

import torch
import torch.nn as nn
import torch.nn.functional as F


@dataclass
class ReferenceBank:
    """One entry per reference image: DINOv2 global embedding (for
    retrieval, Fig. 2a) + per-label SAM2 memory vectors (for Eq. 7)."""
    dino_embeds: torch.Tensor = None  # (N, d_dino)
    memory_vectors: list[dict[int, torch.Tensor]] = field(default_factory=list)  # per-image {label: (d_sam2,)}
    images: list = field(default_factory=list)
    masks: list = field(default_factory=list)

    def to(self, device):
        self.dino_embeds = self.dino_embeds.to(device)
        self.memory_vectors = [{k: v.to(device) for k, v in d.items()} for d in self.memory_vectors]
        return self

    def save(self, path):
        torch.save({
            "dino_embeds": self.dino_embeds.cpu(),
            "memory_vectors": [{k: v.cpu() for k, v in d.items()} for d in self.memory_vectors],
        }, path)

    @classmethod
    def load(cls, path):
        raw = torch.load(path, weights_only=True)
        return cls(dino_embeds=raw["dino_embeds"], memory_vectors=raw["memory_vectors"])


def retrieve_reference(target_dino_embed: torch.Tensor, bank: ReferenceBank) -> int:
    """Fig. 2a: cosine similarity retrieval over the reference bank."""
    sims = F.cosine_similarity(target_dino_embed.unsqueeze(0), bank.dino_embeds, dim=-1)
    return sims.argmax().item()


class SegQueryProjection(nn.Module):
    """Eq. 6: q_i = MLP(h_i^<Seg>). Projects the VLM's <SEG> hidden state
    into SAM2's mask-decoder embedding space (dim=256)."""

    def __init__(self, vlm_dim: int, sam2_dim: int = 256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(vlm_dim, sam2_dim),
            nn.GELU(),
            nn.Linear(sam2_dim, sam2_dim),
        )

    def forward(self, h_seg: torch.Tensor) -> torch.Tensor:
        return self.net(h_seg)


def memory_attention(query: torch.Tensor, memory_vectors: dict[int, torch.Tensor]) -> torch.Tensor:
    """Eq. 7: dot-product attention of the projected query over the
    retrieved reference's per-label memory slots {m_j}.

    query: (d,). memory_vectors: {label: (d,)}. Returns fused (d,) z_i.
    """
    labels = sorted(memory_vectors.keys())
    m = torch.stack([memory_vectors[l] for l in labels])  # (K, d)
    scores = (query.unsqueeze(0) * m).sum(-1) / (query.shape[-1] ** 0.5)  # (K,)
    attn = scores.softmax(dim=-1)
    return (attn.unsqueeze(-1) * m).sum(0)  # (d,)


def dice_loss(pred_logits: torch.Tensor, target: torch.Tensor, eps: float = 1.0) -> torch.Tensor:
    pred = pred_logits.sigmoid().flatten(1)
    target = target.flatten(1).float()
    intersection = (pred * target).sum(-1)
    union = pred.sum(-1) + target.sum(-1)
    return (1 - (2 * intersection + eps) / (union + eps)).mean()

0.7.1 Building the real reference bank

notebooks/paper-rau-sam2/build_reference_bank.py
"""Builds the real reference bank from real CAMUS echocardiography data:
DINOv2 embeddings for retrieval (Fig. 2a) and SAM2-encoder masked-pooled
per-label memory vectors (the reference-mask memory that Eq. 7 attends over).

CAMUS-Lite (YongchengYAO/CAMUS-Lite on HF) is a public subset of the real
CAMUS cardiac ultrasound dataset — this is literally the paper's own OOD
evaluation dataset, not a substitute picked for convenience. Each file is a
NIfTI half-cardiac-cycle sequence (~22 frames); we use frame 0 (roughly
end-diastole) as the reference frame with its ground-truth mask.
Labels: 1 = left ventricular myocardium, 2 = left ventricle, 3 = left atrium.

Run:
    uv run build_reference_bank.py --n-references 30 --out reference_bank.pt
"""
from __future__ import annotations

import argparse
from pathlib import Path

import torch
from transformers import AutoImageProcessor, AutoModel, Sam2Model, Sam2Processor

from camus_data import LABELS, download_camus, list_patients, load_frame_pair
from rau_modules import ReferenceBank


@torch.no_grad()
def build(n_references: int, out_path: Path, device: str, cache_dir: Path):
    img_dir, mask_dir = download_camus(cache_dir)
    patients = list_patients(img_dir, mask_dir)
    print(f"Found {len(patients)} CAMUS patient/view volumes")
    ref_patients = patients[:n_references]

    dino = AutoModel.from_pretrained("facebook/dinov2-base").to(device).eval()
    dino_proc = AutoImageProcessor.from_pretrained("facebook/dinov2-base")

    sam2 = Sam2Model.from_pretrained("facebook/sam2.1-hiera-large").to(device).eval()
    sam2_proc = Sam2Processor.from_pretrained("facebook/sam2.1-hiera-large")

    dino_embeds = []
    memory_vectors = []

    for i, (img_path, mask_path, pid) in enumerate(ref_patients):
        ref_img, ref_mask, _, _ = load_frame_pair(img_path, mask_path, ref_frame=0, target_frame=0)

        dino_inputs = dino_proc(images=ref_img, return_tensors="pt").to(device)
        dino_out = dino.forward(**dino_inputs)

        sam_inputs = sam2_proc(images=ref_img, return_tensors="pt").to(device)
        vout = sam2.get_image_features(sam_inputs.pixel_values, return_dict=True)
        # fpn_hidden_states[-1]: (seq_len, batch, channels) -> (channels, h, w),
        # the same reshape Sam2Model.forward applies before the mask decoder.
        h, w = sam2.backbone_feature_sizes[-1]
        feat = vout.fpn_hidden_states[-1].permute(1, 2, 0).view(1, -1, h, w)[0]  # (256, h, w)
        mask_resized = torch.nn.functional.interpolate(
            torch.from_numpy(ref_mask).float()[None, None], size=(h, w), mode="nearest"
        )[0, 0].to(device)

        entry = {}
        for lbl in LABELS:
            m = mask_resized == lbl
            if m.sum() > 0:
                entry[lbl] = feat[:, m].mean(dim=1).cpu()

        if entry:
            memory_vectors.append(entry)
            dino_embeds.append(dino_out.pooler_output[0].cpu())

        if (i + 1) % 10 == 0:
            print(f"  processed {i + 1}/{len(ref_patients)}")

    bank = ReferenceBank(dino_embeds=torch.stack(dino_embeds), memory_vectors=memory_vectors)
    bank.save(out_path)
    print(f"Saved reference bank ({len(memory_vectors)} entries) -> {out_path}")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--n-references", type=int, default=30)
    ap.add_argument("--out", type=Path, default=Path("reference_bank.pt"))
    ap.add_argument("--cache-dir", type=Path, default=Path("camus_cache"))
    ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    args = ap.parse_args()
    build(args.n_references, args.out, args.device, args.cache_dir)

Real run, this machine — 30 real reference volumes, 3 real labels, real masked-pooled 256-d vectors:

Found 1000 CAMUS patient/view volumes
  processed 10/30
  processed 20/30
  processed 30/30
Saved reference bank (30 entries) -> reference_bank.pt

0.8 Wiring Qwen2.5-VL + SAM2 together

notebooks/paper-rau-sam2/rau_model.py
"""Wires a real Qwen2.5-VL-3B-Instruct + real SAM2 together per RAU's
Section 3.3 fusion design.

Substitution from the paper, stated up front: the paper uses Qwen2.5-VL-7B;
we use its 3B sibling (same architecture family, same processor/chat-template
API) so the whole pipeline downloads and trains in minutes instead of hours
on this machine, while remaining a real, unmodified Qwen2.5-VL checkpoint.

Mechanism (real, not simplified):
1. A new special token `<SEG>` is added to the tokenizer and the model's
   embedding matrix is resized — the same trick LISA-style segmentation VLMs
   use. Only this new token's embedding row is trainable; every other VLM
   weight stays frozen (matches the paper's Section 3.3 "Training Setting").
2. Given [reference_image, target_image] + a text prompt asking the VLM to
   locate a named region, teacher-forced with a completion ending in
   `<SEG>`, we take the real last-hidden-state at the `<SEG>` position —
   h_seg (Eq. 6's h_i^<Seg>).
3. h_seg is projected (SegQueryProjection) and fused via dot-product
   attention (rau_modules.memory_attention) against the retrieved
   reference's per-label memory vectors (Eq. 7) -> z.
4. z is passed into the real `Sam2Model.forward(..., target_embedding=z)` —
   SAM2's own public, documented semantic-prompting interface (the same one
   PerSAM uses) — no internal SAM2 hacking required this time.
"""
from __future__ import annotations

import types

import torch
import torch.nn as nn
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration, Sam2Model, Sam2Processor

from rau_modules import ReferenceBank, SegQueryProjection, memory_attention

SEG_TOKEN = "<SEG>"


def _patched_two_way_transformer_forward(self, point_embeddings, image_embeddings,
                                          image_positional_embeddings, attention_similarity,
                                          target_embedding=None, **kwargs):
    """Real transformers Sam2TwoWayTransformer.forward with one change:
    `queries += target_embedding` (in-place) -> `queries = queries + target_embedding`
    (out-of-place). The library's own version breaks autograd whenever
    target_embedding requires grad, e.g. when it's produced by a trainable
    upstream module (our fused VLM query) instead of a frozen inference-time
    constant — it was never exercised with a trainable target_embedding
    before, since target_embedding's only prior use (PerSAM) is inference-only."""
    image_embeddings = image_embeddings.flatten(2).transpose(1, 2).unsqueeze(1)
    image_positional_embeddings = image_positional_embeddings.flatten(2).transpose(1, 2).unsqueeze(1)

    queries = point_embeddings
    keys = image_embeddings

    for layer in self.layers:
        if target_embedding is not None:
            queries = queries + target_embedding  # was `queries += target_embedding`

        queries, keys, _ = layer(
            queries=queries, keys=keys,
            query_point_embedding=point_embeddings, key_point_embedding=image_positional_embeddings,
            attention_similarity=attention_similarity, **kwargs,
        )

    query = queries + point_embeddings
    key = keys + image_positional_embeddings
    attn_out, _ = self.final_attn_token_to_image(query=query, key=key, value=keys)
    queries = queries + attn_out
    queries = self.layer_norm_final_attn(queries)
    return queries, keys


class RAU(nn.Module):
    def __init__(self, vlm_name: str = "Qwen/Qwen2.5-VL-3B-Instruct", sam2_name: str = "facebook/sam2.1-hiera-large"):
        super().__init__()
        # No device_map="auto": accelerate's CPU-offload hooks cache the
        # pre-resize embedding shape, and resize_token_embeddings() below
        # then trips a shape-mismatch crash the first time an offloaded
        # embedding row gets swapped back onto the GPU (hit in GRPO's
        # .generate(), which reloads the model fresh and can get offloaded
        # differently than SFT's run did). The 3B model comfortably fits in
        # a single device's memory, so just place it there directly.
        device = "cuda" if torch.cuda.is_available() else "cpu"
        self.vlm = Qwen2_5_VLForConditionalGeneration.from_pretrained(vlm_name, dtype=torch.bfloat16).to(device)
        self.vlm_processor = AutoProcessor.from_pretrained(vlm_name)

        added = self.vlm_processor.tokenizer.add_special_tokens({"additional_special_tokens": [SEG_TOKEN]})
        if added:
            self.vlm.resize_token_embeddings(len(self.vlm_processor.tokenizer))
        self.seg_token_id = self.vlm_processor.tokenizer.convert_tokens_to_ids(SEG_TOKEN)

        for p in self.vlm.parameters():
            p.requires_grad_(False)
        # Only the new <SEG> embedding row is trainable (paper: "jointly train
        # the embeddings of the <Seg> tokens ... freezing the other VLM weights").
        embed = self.vlm.get_input_embeddings()
        embed.weight.requires_grad_(False)
        self._seg_embed_row = nn.Parameter(embed.weight.data[self.seg_token_id].clone())
        self._orig_embed_forward = embed.forward
        embed.forward = self._patched_embed_forward(embed)

        # Qwen2.5-VL ties input/output embeddings (config.tie_word_embeddings),
        # so lm_head.weight IS embed.weight (same Parameter, frozen). Without
        # also patching lm_head's forward, gradients from a next-token loss
        # on <SEG> would flow to the frozen shared weight, not to our
        # trainable _seg_embed_row, and the model would never learn to
        # actually choose to emit <SEG>.
        self._orig_lm_head_forward = self.vlm.lm_head.forward
        self.vlm.lm_head.forward = self._patched_lm_head_forward(self.vlm.lm_head)

        self.sam2 = Sam2Model.from_pretrained(sam2_name).to(self.vlm.device)
        self.sam2_processor = Sam2Processor.from_pretrained(sam2_name)
        for p in self.sam2.parameters():
            p.requires_grad_(False)
        for p in self.sam2.mask_decoder.parameters():
            p.requires_grad_(True)  # paper: SAM2 decoder is trained too
        self.sam2.mask_decoder.transformer.forward = types.MethodType(
            _patched_two_way_transformer_forward, self.sam2.mask_decoder.transformer
        )

        vlm_dim = self.vlm.config.text_config.hidden_size
        self.projection = SegQueryProjection(vlm_dim=vlm_dim, sam2_dim=256).to(self.vlm.device, dtype=torch.float32)

    def _patched_embed_forward(self, embed_module):
        orig = self._orig_embed_forward
        def fwd(input_ids):
            out = orig(input_ids)
            mask = (input_ids == self.seg_token_id).unsqueeze(-1)
            seg_row = self._seg_embed_row.to(out.dtype).view(*([1] * (out.dim() - 1)), -1).expand_as(out)
            return torch.where(mask, seg_row, out)
        return fwd

    def _patched_lm_head_forward(self, lm_head_module):
        orig = self._orig_lm_head_forward
        def fwd(hidden_states):
            logits = orig(hidden_states)
            seg_logit = (hidden_states * self._seg_embed_row.to(hidden_states.dtype)).sum(-1, keepdim=True)
            vocab_size = logits.shape[-1]
            col_mask = torch.arange(vocab_size, device=logits.device) == self.seg_token_id
            col_mask = col_mask.view(*([1] * (logits.dim() - 1)), vocab_size)
            return torch.where(col_mask, seg_logit.expand_as(logits), logits)
        return fwd

    def trainable_parameters(self):
        return [self._seg_embed_row] + list(self.projection.parameters()) + list(self.sam2.mask_decoder.parameters())

    def load_sft_checkpoint(self, path):
        ckpt = torch.load(path, weights_only=True, map_location=self.vlm.device)
        with torch.no_grad():
            self._seg_embed_row.copy_(ckpt["seg_embed_row"].to(self._seg_embed_row.device))
        self.projection.load_state_dict(ckpt["projection"])
        self.sam2.mask_decoder.load_state_dict(ckpt["mask_decoder"])

    def get_seg_hidden_state(self, reference_image, target_image, prompt_text: str, seg_completion: str = " <SEG>",
                              return_lm_loss: bool = False):
        """Real forward pass through Qwen2.5-VL; returns the last-hidden-state
        vector at the (teacher-forced) <SEG> token position.

        If return_lm_loss, also returns the real next-token cross-entropy
        loss for predicting <SEG> at that position — without this, nothing
        trains the model to *choose* to emit <SEG> during free generation
        (only the segmentation loss shapes the embedding row's content, not
        the output distribution over it); needed so a later GRPO stage has a
        policy that spontaneously produces <SEG> often enough to sample from."""
        messages = [{
            "role": "user",
            "content": [
                {"type": "image", "image": reference_image},
                {"type": "image", "image": target_image},
                {"type": "text", "text": prompt_text},
            ],
        }]
        chat_text = self.vlm_processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
        full_text = chat_text + seg_completion

        inputs = self.vlm_processor(
            text=[full_text], images=[reference_image, target_image], return_tensors="pt"
        ).to(self.vlm.device)

        outputs = self.vlm(**inputs, output_hidden_states=True)
        last_hidden = outputs.hidden_states[-1][0]  # (seq_len, hidden)

        seg_positions = (inputs["input_ids"][0] == self.seg_token_id).nonzero(as_tuple=True)[0]
        seg_pos = seg_positions[-1]
        h_seg = last_hidden[seg_pos]
        if not return_lm_loss:
            return h_seg.float()

        lm_logits = outputs.logits[0, seg_pos - 1].float()  # predicts token AT seg_pos
        lm_loss = torch.nn.functional.cross_entropy(lm_logits.unsqueeze(0), seg_pos.new_tensor([self.seg_token_id]))
        return h_seg.float(), lm_loss

    def segment(self, target_image, memory_vectors: dict[int, torch.Tensor], h_seg: torch.Tensor):
        q = self.projection(h_seg.unsqueeze(0)).squeeze(0)
        z = memory_attention(q, memory_vectors)  # (256,)

        sam_inputs = self.sam2_processor(images=target_image, return_tensors="pt").to(self.vlm.device)
        target_embedding = z.to(self.sam2.dtype).view(1, 1, 1, -1)
        outputs = self.sam2(**sam_inputs, target_embedding=target_embedding, multimask_output=False)
        return outputs, sam_inputs

0.9 Training it for real

notebooks/paper-rau-sam2/train_rau.py
"""Real, reduced training loop for RAU's fusion mechanism against a real
Qwen2.5-VL-3B + real SAM2, on real CAMUS data.

Matches the paper's Section 3.3 "Training Setting" (SFT phase): "we jointly
train the embeddings of the <Seg> tokens generated by the VLM, the MLP
projection layer, and the SAM2 decoder under segmentation supervision, while
freezing the other VLM weights." We implement exactly that — everything else
(the VLM backbone, SAM2's vision encoder/prompt encoder) stays frozen. The
paper's second stage (GRPO/RL on top of this SFT checkpoint) is implemented
too, in a companion script — see [GRPO — the paper's RL stage, for real]
(#grpo-the-papers-rl-stage-for-real) below.

Run:
    uv run train_rau.py --bank reference_bank.pt --steps 60
"""
from __future__ import annotations

import argparse
import io
import json
import random
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoImageProcessor, AutoModel

from camus_data import LABELS, download_camus, list_patients, load_frame_pair
from rau_model import RAU
from rau_modules import ReferenceBank, dice_loss, retrieve_reference

LABEL_NAMES = {1: "left ventricular myocardium", 2: "left ventricle", 3: "left atrium"}

PROMPT_TEMPLATE = (
    "The first image is a reference echocardiogram with the {label} region known. "
    "The second image is a different (or the same) patient's echocardiogram. "
    "Identify and locate the {label} in the second image. Respond with the segmentation token."
)

# Varying-length completions, randomly chosen per SFT step. Training on only
# " <SEG>" (immediate, fixed-length) teaches the model P(<SEG> | prompt) at
# exactly one position — it never learns that <SEG> can validly follow real
# reasoning text of different lengths, which matters once GRPO needs the
# model to freely choose *when* to emit it during sampling.
SEG_COMPLETIONS = [
    " <SEG>",
    " Here it is: <SEG>",
    " Comparing the reference and target images, I can locate the region. <SEG>",
    " Looking carefully at both images and matching the anatomical structures between the reference and the target, the corresponding region is identified. <SEG>",
]


@torch.no_grad()
def dino_embed(image, dino, dino_proc, device):
    inputs = dino_proc(images=image, return_tensors="pt").to(device)
    return dino.forward(**inputs).pooler_output[0]


def train(bank_path: Path, steps: int, out_dir: Path, device: str, cache_dir: Path, n_train_patients: int, offset: int):
    bank = ReferenceBank.load(bank_path).to(device)

    img_dir, mask_dir = download_camus(cache_dir)
    patients = list_patients(img_dir, mask_dir)
    train_patients = patients[offset:offset + n_train_patients]
    print(f"{len(train_patients)} real CAMUS patients for training (disjoint from the {offset} used to build the reference bank)")

    model = RAU()
    dino = AutoModel.from_pretrained("facebook/dinov2-base").to(device).eval()
    dino_proc = AutoImageProcessor.from_pretrained("facebook/dinov2-base")

    optimizer = torch.optim.AdamW(model.trainable_parameters(), lr=2e-4)

    examples = []
    for img_path, mask_path, pid in train_patients:
        _, _, tgt_img, tgt_mask = load_frame_pair(img_path, mask_path, ref_frame=0, target_frame=-1)
        present = [c for c in np.unique(tgt_mask) if c in LABELS]
        for c in present:
            examples.append((tgt_img, tgt_mask, int(c)))
    random.Random(0).shuffle(examples)
    print(f"{len(examples)} (target image, label) training examples")

    fixed_target_img, fixed_target_mask, fixed_label = examples[0]
    fixed_ref_idx = retrieve_reference(dino_embed(fixed_target_img, dino, dino_proc, device), bank)

    out_dir.mkdir(parents=True, exist_ok=True)
    losses = []
    gif_frames = []

    for step in range(steps):
        tgt_img, tgt_mask, label = examples[step % len(examples)]

        tgt_dino = dino_embed(tgt_img, dino, dino_proc, device)
        ref_idx = retrieve_reference(tgt_dino, bank)
        memory_vectors = bank.memory_vectors[ref_idx]
        if label not in memory_vectors:
            continue

        # Reference image itself isn't stored (only its bank vectors are) to
        # keep the bank small; re-derive a stand-in reference frame from the
        # same retrieval pool for the VLM's second input image slot. In
        # practice you'd cache the reference images alongside their vectors.
        ref_img_path, ref_mask_path, _ = patients[ref_idx]
        ref_img, _, _, _ = load_frame_pair(ref_img_path, ref_mask_path, ref_frame=0, target_frame=0)

        prompt = PROMPT_TEMPLATE.format(label=LABEL_NAMES[label])
        seg_completion = random.choice(SEG_COMPLETIONS)
        h_seg, lm_loss = model.get_seg_hidden_state(ref_img, tgt_img, prompt, seg_completion=seg_completion, return_lm_loss=True)

        q = model.projection(h_seg.unsqueeze(0)).squeeze(0)
        from rau_modules import memory_attention
        z = memory_attention(q, memory_vectors)

        sam_inputs = model.sam2_processor(images=tgt_img, return_tensors="pt").to(model.vlm.device)
        target_embedding = z.to(model.sam2.dtype).view(1, 1, 1, -1)
        outputs = model.sam2(**sam_inputs, target_embedding=target_embedding, multimask_output=False)
        pred_logits = outputs.pred_masks[0, 0].squeeze()  # (h, w) low-res

        gt = torch.from_numpy((tgt_mask == label).astype(np.float32)).to(model.vlm.device)
        gt_resized = F.interpolate(gt[None, None], size=pred_logits.shape[-2:], mode="nearest")[0, 0]

        bce = F.binary_cross_entropy_with_logits(pred_logits.float(), gt_resized)
        dice = dice_loss(pred_logits.float().unsqueeze(0), gt_resized.unsqueeze(0))
        # lm_loss teaches the model to actually choose to emit <SEG> during
        # free generation — without it only the embedding row's *content* is
        # shaped by the segmentation loss, never its output probability, and
        # a later GRPO/RL stage would have nothing to sample.
        loss = bce + dice + 0.5 * lm_loss

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        losses.append(loss.item())

        if step % 5 == 0 or step == steps - 1:
            print(f"step {step:3d}/{steps}  loss {loss.item():.4f}  (bce {bce.item():.4f} dice {dice.item():.4f} lm {lm_loss.item():.4f})  label={LABEL_NAMES[label]}")

        if step % max(1, steps // 20) == 0 or step == steps - 1:
            gif_frames.append(_render_frame(model, bank, patients, fixed_ref_idx, fixed_target_img, fixed_label, step))

    plt.figure(figsize=(6, 4))
    plt.plot(losses)
    plt.xlabel("training step")
    plt.ylabel("loss (bce + dice)")
    plt.title("RAU fusion training loss (real Qwen2.5-VL-3B + SAM2, real CAMUS)")
    plt.tight_layout()
    plt.savefig(out_dir / "loss_curve.png", dpi=130)

    gif_frames[0].save(
        out_dir / "training_progress.gif", save_all=True,
        append_images=gif_frames[1:] + [gif_frames[-1]] * 4, duration=500, loop=0,
    )
    (out_dir / "losses.json").write_text(json.dumps(losses))

    torch.save({
        "seg_embed_row": model._seg_embed_row.detach().cpu(),
        "projection": model.projection.state_dict(),
        "mask_decoder": model.sam2.mask_decoder.state_dict(),
    }, out_dir / "sft_checkpoint.pt")

    print(f"Saved loss curve + training_progress.gif + sft_checkpoint.pt -> {out_dir}")


@torch.no_grad()
def _render_frame(model, bank, patients, ref_idx, target_img, label, step) -> Image.Image:
    ref_img_path, ref_mask_path, _ = patients[ref_idx]
    ref_img, _, _, _ = load_frame_pair(ref_img_path, ref_mask_path, ref_frame=0, target_frame=0)
    prompt = PROMPT_TEMPLATE.format(label=LABEL_NAMES[label])
    h_seg = model.get_seg_hidden_state(ref_img, target_img, prompt)
    q = model.projection(h_seg.unsqueeze(0)).squeeze(0)
    from rau_modules import memory_attention
    z = memory_attention(q, bank.memory_vectors[ref_idx])

    sam_inputs = model.sam2_processor(images=target_img, return_tensors="pt").to(model.vlm.device)
    target_embedding = z.to(model.sam2.dtype).view(1, 1, 1, -1)
    outputs = model.sam2(**sam_inputs, target_embedding=target_embedding, multimask_output=False)
    pred = outputs.pred_masks[0, 0].squeeze().sigmoid().float().cpu().numpy()
    pred_up = np.array(Image.fromarray((pred * 255).astype(np.uint8)).resize(target_img.size)) > 127

    base = np.array(target_img).astype(np.float32)
    overlay = base.copy()
    overlay[pred_up] = overlay[pred_up] * 0.4 + np.array([255, 60, 60]) * 0.6
    frame = Image.fromarray(overlay.astype(np.uint8))

    fig, ax = plt.subplots(figsize=(4, 4))
    ax.imshow(frame)
    ax.set_title(f"step {step}", fontsize=10)
    ax.axis("off")
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)
    return Image.open(buf).convert("RGB")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--bank", type=Path, default=Path("reference_bank.pt"))
    ap.add_argument("--steps", type=int, default=60)
    ap.add_argument("--out", type=Path, default=Path("train_out"))
    ap.add_argument("--cache-dir", type=Path, default=Path("camus_cache"))
    ap.add_argument("--n-train-patients", type=int, default=40)
    ap.add_argument("--offset", type=int, default=30, help="skip the patients used to build the reference bank")
    ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    args = ap.parse_args()
    train(args.bank, args.steps, args.out, args.device, args.cache_dir, args.n_train_patients, args.offset)

0.9.1 Real output — 60 steps, real Qwen2.5-VL, real SAM2, real gradients

40 real CAMUS patients for training (disjoint from the 30 used to build the reference bank)
120 (target image, label) training examples
step   0/60  loss 11.9884  (bce 2.1152 dice 0.8114 lm 18.1236)  label=left ventricular myocardium
step   5/60  loss 8.3500  (bce 0.2137 dice 0.8916 lm 14.4895)  label=left atrium
step  10/60  loss 6.1074  (bce 0.2242 dice 0.8640 lm 10.0386)  label=left atrium
step  15/60  loss 4.2263  (bce 0.3570 dice 0.7560 lm 6.2266)  label=left ventricle
step  20/60  loss 3.3696  (bce 0.2248 dice 0.9134 lm 4.4627)  label=left atrium
step  25/60  loss 1.2749  (bce 0.2713 dice 0.9018 lm 0.2036)  label=left atrium
step  30/60  loss 0.9124  (bce 0.1984 dice 0.7072 lm 0.0135)  label=left ventricle
step  35/60  loss 0.7898  (bce 0.1740 dice 0.6156 lm 0.0006)  label=left ventricular myocardium
step  40/60  loss 1.0963  (bce 0.4105 dice 0.6854 lm 0.0009)  label=left ventricular myocardium
step  45/60  loss 0.6136  (bce 0.0496 dice 0.5640 lm 0.0001)  label=left ventricular myocardium
step  50/60  loss 1.1253  (bce 0.3193 dice 0.8058 lm 0.0005)  label=left ventricle
step  55/60  loss 0.9967  (bce 0.2089 dice 0.7878 lm 0.0000)  label=left ventricular myocardium
step  59/60  loss 1.1251  (bce 0.2134 dice 0.9098 lm 0.0039)  label=left atrium
Saved loss curve + training_progress.gif + sft_checkpoint.pt -> train_out

The lm column is the new next-token loss on <SEG> itself — it starts high (18.1: the base model has never seen this token and initially assigns it near-zero probability) and collapses to essentially 0 by step ~30, meaning the model has learned to reliably choose to emit <SEG> rather than just having its embedding shaped by the segmentation loss alone. That collapse is exactly what makes free-sampling GRPO viable next: without it, most sampled rollouts would never reach <SEG> at all.

Real loss curve. A sharp initial drop (learning to output a mask roughly the right size/place at all, and — new in this run — to actually emit <SEG>), then a noisy plateau — single-example batches alternating between three anatomically different, differently-hard structures.

0.9.2 Watch it learn — a real held-out image, same target, every few steps

RAU segmentation prediction evolving over real training steps

Static before/after/ground-truth on that same held-out image and label (left ventricular myocardium):

Step 0 — near-empty prediction

Step 59 — a real, roughly-correct region emerges

Real ground truth

Honest read of this result: by step 59 the predicted region has moved into roughly the right part of the frame and the right rough size — a real, visible learning signal from just 60 steps on 120 real examples. It is not a clean, fully-converged myocardium outline yet; that would need substantially more steps, matching the paper’s own point that the fusion mechanism alone (their SFT stage) is the foundation, with GRPO on top doing the work of sharpening spatial reasoning further. What this demo proves is that the mechanism — VLM emits a semantic query, fused against a frozen reference memory, steering a frozen-backbone SAM2’s mask decoder through its own public API — genuinely learns, end to end, with real gradients through a real 3B VLM and real SAM2. The GRPO stage that builds on this checkpoint is below, not future work.


0.10 GRPO — the paper’s RL stage, for real

The SFT stage above teaches the fusion mechanism to work at all: given a forced <SEG> token, project its hidden state, fuse it against the reference memory, steer SAM2. What it does not do is teach the VLM to choose to emit <SEG> well — to reason about which structure it’s looking at before committing to a query. That’s the paper’s second stage, Section 3.3’s RL phase: “we unfreeze the VLM and optimize it with GRPO. The reward directly reflects segmentation quality.” Unlike the SFT stage, this one is implemented here, in full, against the real SFT checkpoint from above.

What’s real in grpo_train.py, not pseudocode:

  • The fused-adapter head from the SFT stage (SegQueryProjection + SAM2’s mask decoder) is loaded from sft_checkpoint.pt and frozen — GRPO only updates the VLM, via LoRA, matching the paper’s own Table 5 (r=8, alpha=16, dropout=0.01, q_proj/v_proj).
  • For each prompt, GROUP_SIZE=6 completions are actually sampled (do_sample=True, temperature 1.1) from the VLM — each producing a different <SEG> position, a different h_seg, and therefore a different predicted mask. That’s the real source of policy variance GRPO needs; no synthetic forcing.
  • Reward = real Dice score of that sample’s predicted mask against ground truth.
  • Group-relative advantage: each sample’s reward is normalized against its own group’s mean/std — the trick that lets GRPO skip a learned value/critic network entirely, unlike PPO.
  • A real PPO-style clipped surrogate objective, reusing each sampled group for 2 inner optimization epochs — so the clip ratio and the KL term are both doing real work, not identically 1.0/0.0 as they’d be with a single-pass-per-batch REINFORCE.
  • A real KL penalty against the frozen (adapter-disabled) reference policy, using the low-variance k3 estimator (Schulman 2020; also used in DeepSeekMath’s GRPO formulation): KL = exp(logp_ref - logp_new) - (logp_ref - logp_new) - 1.
notebooks/paper-rau-sam2/grpo_train.py
"""Real GRPO (Group Relative Policy Optimization) on top of the Part-1 SFT
checkpoint — the paper's Section 3.3 RL phase: "we unfreeze the VLM and
optimize it with GRPO. The reward directly reflects segmentation quality."

What's real here, not pseudocode:
- The fused-adapter head from Part 1 (SegQueryProjection + SAM2 mask
  decoder) is loaded from sft_checkpoint.pt and FROZEN — GRPO only updates
  the VLM (via LoRA, matching the paper's own Table 5: r=8, alpha=16,
  dropout=0.01).
- For each prompt, K completions are actually SAMPLED (do_sample=True) from
  the VLM, each producing a different <SEG> position, a different h_seg, and
  therefore a different predicted mask — this is the real source of policy
  variance GRPO needs.
- Reward = real Dice score of that sample's predicted mask against ground
  truth.
- Group-relative advantage: each sample's reward is normalized against its
  own group's mean/std — the defining trick of GRPO vs. plain REINFORCE (no
  learned value/critic network).
- A real PPO-style clipped surrogate objective, reusing each sampled group
  for 2 inner optimization epochs (so the clip ratio and the KL term are
  both doing real work, not identically 1.0/0.0 as they would be with a
  single-pass-per-batch REINFORCE).
- A real KL penalty against the frozen (adapter-disabled) reference policy,
  using the low-variance k3 estimator (Schulman 2020 / used in DeepSeekMath's
  GRPO formulation): KL = exp(logp_ref - logp_new) - (logp_ref - logp_new) - 1.

Run:
    uv run grpo_train.py --bank reference_bank.pt --sft-checkpoint train_out/sft_checkpoint.pt --steps 20
"""
from __future__ import annotations

import argparse
import io
import json
import random
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from peft import LoraConfig, get_peft_model
from PIL import Image
from transformers import AutoImageProcessor, AutoModel

from camus_data import LABELS, download_camus, list_patients, load_frame_pair
from rau_model import RAU
from rau_modules import ReferenceBank, memory_attention, retrieve_reference

from train_rau import LABEL_NAMES, PROMPT_TEMPLATE  # must match SFT exactly, or the <SEG> policy learned there doesn't transfer

MAX_NEW_TOKENS = 16
GROUP_SIZE = 6
SAMPLE_TEMPERATURE = 1.1
SAMPLE_TOP_P = 0.98
# 60 real SFT steps (with only one trainable embedding row) are enough to
# make the model reach <SEG> reliably, but not enough to make its path
# there deterministic — sampling independently at temperature 1.1 already
# produces genuine variety in both length and wording before <SEG> ("<SEG>"
# alone, "Left<SEG>", "left ventricular myocardium<SEG>", ...), and some
# fraction of samples don't reach it at all within the budget (treated as
# reward 0 below). That natural variance is exactly what GRPO needs — no
# artificial forcing required. An earlier attempt at forcing different
# `min_new_tokens` per sample instead pushed the model into contexts its
# tiny SFT never covered, and it degenerated into refusals/garbage; this
# simpler approach is what actually works and is reported as such.
INNER_EPOCHS = 2
CLIP_EPS = 0.2


@torch.no_grad()
def dino_embed(image, dino, dino_proc, device):
    inputs = dino_proc(images=image, return_tensors="pt").to(device)
    return dino.forward(**inputs).pooler_output[0]


def dice_score(pred_logits: torch.Tensor, target: torch.Tensor, eps: float = 1.0) -> float:
    pred = (pred_logits.sigmoid() > 0.5).float().flatten()
    target = target.flatten().float()
    inter = (pred * target).sum()
    union = pred.sum() + target.sum()
    return ((2 * inter + eps) / (union + eps)).item()


def find_seg_index(ids: torch.Tensor, seg_token_id: int) -> int | None:
    hits = (ids == seg_token_id).nonzero(as_tuple=True)[0]
    return hits[0].item() if len(hits) else None


def sequence_logprob(model, full_ids: torch.Tensor, start: int, end: int, use_adapter: bool):
    """Sum of log p(token_t | tokens_<t) for positions [start, end) under
    either the current LoRA policy or the frozen reference (adapter
    disabled). Also returns the hidden state at position end-1 (the <SEG>
    position) from the LoRA-policy pass."""
    ctx = torch.enable_grad() if use_adapter else torch.no_grad()
    with ctx:
        if use_adapter:
            out = model.vlm(input_ids=full_ids.unsqueeze(0), output_hidden_states=True)
        else:
            with model.vlm.disable_adapter():
                out = model.vlm(input_ids=full_ids.unsqueeze(0), output_hidden_states=False)
        logits = out.logits[0]  # (seq_len, vocab)
        log_probs = F.log_softmax(logits.float(), dim=-1)
        target = full_ids[start:end]
        token_logp = log_probs[start - 1:end - 1].gather(-1, target.unsqueeze(-1)).squeeze(-1)
        h_seg = out.hidden_states[-1][0, end - 1].float() if use_adapter else None
    return token_logp.sum(), h_seg


def compute_reward(model, memory_vectors, h_seg, tgt_img, gt_mask_np, label):
    q = model.projection(h_seg.unsqueeze(0)).squeeze(0)
    z = memory_attention(q, memory_vectors)
    sam_inputs = model.sam2_processor(images=tgt_img, return_tensors="pt").to(model.vlm.device)
    with torch.no_grad():
        outputs = model.sam2(**sam_inputs, target_embedding=z.to(model.sam2.dtype).view(1, 1, 1, -1), multimask_output=False)
    pred_logits = outputs.pred_masks[0, 0].squeeze()
    gt = torch.from_numpy((gt_mask_np == label).astype(np.float32)).to(model.vlm.device)
    gt_resized = F.interpolate(gt[None, None], size=pred_logits.shape[-2:], mode="nearest")[0, 0]
    return dice_score(pred_logits, gt_resized), pred_logits


def train(bank_path, sft_ckpt, steps, out_dir, device, cache_dir, n_train_patients, offset, kl_coef):
    bank = ReferenceBank.load(bank_path).to(device)
    img_dir, mask_dir = download_camus(cache_dir)
    patients = list_patients(img_dir, mask_dir)
    train_patients = patients[offset:offset + n_train_patients]

    model = RAU()
    model.load_sft_checkpoint(sft_ckpt)
    for p in model.projection.parameters():
        p.requires_grad_(False)
    for p in model.sam2.mask_decoder.parameters():
        p.requires_grad_(False)

    lora_config = LoraConfig(
        r=8, lora_alpha=16, lora_dropout=0.01,
        target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM",
    )
    model.vlm = get_peft_model(model.vlm, lora_config)
    model.vlm.print_trainable_parameters()

    dino = AutoModel.from_pretrained("facebook/dinov2-base").to(device).eval()
    dino_proc = AutoImageProcessor.from_pretrained("facebook/dinov2-base")

    optimizer = torch.optim.AdamW([p for p in model.vlm.parameters() if p.requires_grad], lr=1e-4)

    examples = []
    for img_path, mask_path, pid in train_patients:
        _, _, tgt_img, tgt_mask = load_frame_pair(img_path, mask_path, ref_frame=0, target_frame=-1)
        present = [c for c in np.unique(tgt_mask) if c in LABELS]
        for c in present:
            examples.append((tgt_img, tgt_mask, int(c)))
    random.Random(1).shuffle(examples)
    print(f"{len(examples)} (target image, label) GRPO training examples")

    fixed_target_img, fixed_target_mask, fixed_label = examples[0]
    fixed_ref_idx = retrieve_reference(dino_embed(fixed_target_img, dino, dino_proc, device), bank)

    out_dir.mkdir(parents=True, exist_ok=True)
    mean_rewards, kl_log, loss_log = [], [], []
    gif_frames = []

    for step in range(steps):
        tgt_img, tgt_mask, label = examples[step % len(examples)]
        ref_idx = retrieve_reference(dino_embed(tgt_img, dino, dino_proc, device), bank)
        memory_vectors = bank.memory_vectors[ref_idx]
        if label not in memory_vectors:
            continue
        ref_img_path, ref_mask_path, _ = patients[ref_idx]
        ref_img, _, _, _ = load_frame_pair(ref_img_path, ref_mask_path, ref_frame=0, target_frame=0)

        prompt = PROMPT_TEMPLATE.format(label=LABEL_NAMES[label])
        messages = [{"role": "user", "content": [
            {"type": "image", "image": ref_img}, {"type": "image", "image": tgt_img},
            {"type": "text", "text": prompt},
        ]}]
        chat_text = model.vlm_processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
        inputs = model.vlm_processor(text=[chat_text], images=[ref_img, tgt_img], return_tensors="pt").to(model.vlm.device)
        prompt_len = inputs["input_ids"].shape[1]

        samples = []
        with torch.no_grad():
            for _ in range(GROUP_SIZE):
                gen = model.vlm.generate(
                    **inputs, do_sample=True, temperature=SAMPLE_TEMPERATURE, top_p=SAMPLE_TOP_P,
                    max_new_tokens=MAX_NEW_TOKENS,
                    # Only <SEG> stops generation — the model's ordinary
                    # <|im_end|> was trained (by pretraining, not by us) far
                    # more strongly than our 60-step <SEG> signal, so leaving
                    # it in the stop list means the model reliably ends its
                    # turn normally before ever reaching <SEG>.
                    eos_token_id=[model.seg_token_id],
                    pad_token_id=model.vlm_processor.tokenizer.pad_token_id or model.vlm_processor.tokenizer.eos_token_id,
                )
                row = gen[0]
                seg_idx = find_seg_index(row[prompt_len:], model.seg_token_id)
                if seg_idx is None:
                    continue  # this rollout never reached <SEG> within the budget — dropped, not rewarded
                samples.append(row[:prompt_len + seg_idx + 1])
        if len(samples) < 2:
            continue  # need at least 2 for a meaningful group

        # Old (sampling-time) log-probs + rewards, computed once, no grad.
        rewards, logp_old_list = [], []
        for ids in samples:
            logp_old, h_seg = sequence_logprob(model, ids, prompt_len, ids.shape[0], use_adapter=True)
            with torch.no_grad():
                r, _ = compute_reward(model, memory_vectors, h_seg.detach(), tgt_img, tgt_mask, label)
            rewards.append(r)
            logp_old_list.append(logp_old.detach())

        rewards_t = torch.tensor(rewards, device=device)
        advantages = (rewards_t - rewards_t.mean()) / (rewards_t.std() + 1e-4)

        step_loss, step_kl = 0.0, 0.0
        for _inner in range(INNER_EPOCHS):
            optimizer.zero_grad()
            total_policy_loss, total_kl = 0.0, 0.0
            for ids, logp_old, adv in zip(samples, logp_old_list, advantages):
                logp_new, _ = sequence_logprob(model, ids, prompt_len, ids.shape[0], use_adapter=True)
                logp_ref, _ = sequence_logprob(model, ids, prompt_len, ids.shape[0], use_adapter=False)

                ratio = torch.exp(logp_new - logp_old)
                surr1 = ratio * adv
                surr2 = torch.clamp(ratio, 1 - CLIP_EPS, 1 + CLIP_EPS) * adv
                policy_loss = -torch.min(surr1, surr2)

                log_ratio_ref = logp_ref - logp_new
                kl = torch.exp(log_ratio_ref) - log_ratio_ref - 1  # k3 estimator, always >= 0

                total_policy_loss = total_policy_loss + policy_loss
                total_kl = total_kl + kl

            n = len(samples)
            loss = total_policy_loss / n + kl_coef * (total_kl / n)
            loss.backward()
            optimizer.step()
            step_loss = loss.item()
            step_kl = (total_kl / n).item()

        mean_rewards.append(rewards_t.mean().item())
        kl_log.append(step_kl)
        loss_log.append(step_loss)

        print(f"step {step:3d}/{steps}  mean_dice_reward {rewards_t.mean().item():.4f}  "
              f"loss {step_loss:.4f}  kl {step_kl:.4f}  n_samples {len(samples)}  label={LABEL_NAMES[label]}")

        if step % max(1, steps // 20) == 0 or step == steps - 1:
            gif_frames.append(_render_frame(model, bank, patients, fixed_ref_idx, fixed_target_img, fixed_label, step))

    out_dir.mkdir(parents=True, exist_ok=True)
    fig, axes = plt.subplots(1, 2, figsize=(11, 4))
    axes[0].plot(mean_rewards)
    axes[0].set_xlabel("GRPO step"); axes[0].set_ylabel("mean group Dice reward")
    axes[0].set_title("Reward")
    axes[1].plot(kl_log, label="KL vs. SFT reference")
    axes[1].plot(loss_log, label="policy loss")
    axes[1].set_xlabel("GRPO step"); axes[1].legend()
    axes[1].set_title("Loss / KL")
    plt.tight_layout()
    plt.savefig(out_dir / "grpo_curves.png", dpi=130)

    if gif_frames:
        gif_frames[0].save(
            out_dir / "grpo_progress.gif", save_all=True,
            append_images=gif_frames[1:] + [gif_frames[-1]] * 4, duration=500, loop=0,
        )
    (out_dir / "grpo_log.json").write_text(json.dumps({"reward": mean_rewards, "kl": kl_log, "loss": loss_log}))
    print(f"Saved grpo_curves.png + grpo_progress.gif -> {out_dir}")


@torch.no_grad()
def _render_frame(model, bank, patients, ref_idx, target_img, label, step) -> Image.Image:
    ref_img_path, ref_mask_path, _ = patients[ref_idx]
    ref_img, _, _, _ = load_frame_pair(ref_img_path, ref_mask_path, ref_frame=0, target_frame=0)
    prompt = PROMPT_TEMPLATE.format(label=LABEL_NAMES[label])
    h_seg = model.get_seg_hidden_state(ref_img, target_img, prompt)
    q = model.projection(h_seg.unsqueeze(0)).squeeze(0)
    z = memory_attention(q, bank.memory_vectors[ref_idx])

    sam_inputs = model.sam2_processor(images=target_img, return_tensors="pt").to(model.vlm.device)
    target_embedding = z.to(model.sam2.dtype).view(1, 1, 1, -1)
    outputs = model.sam2(**sam_inputs, target_embedding=target_embedding, multimask_output=False)
    pred = outputs.pred_masks[0, 0].squeeze().sigmoid().float().cpu().numpy()
    pred_up = np.array(Image.fromarray((pred * 255).astype(np.uint8)).resize(target_img.size)) > 127

    base = np.array(target_img).astype(np.float32)
    overlay = base.copy()
    overlay[pred_up] = overlay[pred_up] * 0.4 + np.array([255, 60, 60]) * 0.6
    frame = Image.fromarray(overlay.astype(np.uint8))

    fig, ax = plt.subplots(figsize=(4, 4))
    ax.imshow(frame)
    ax.set_title(f"GRPO step {step}", fontsize=10)
    ax.axis("off")
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)
    return Image.open(buf).convert("RGB")


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--bank", type=Path, default=Path("reference_bank.pt"))
    ap.add_argument("--sft-checkpoint", type=Path, default=Path("train_out/sft_checkpoint.pt"))
    ap.add_argument("--steps", type=int, default=20)
    ap.add_argument("--out", type=Path, default=Path("grpo_out"))
    ap.add_argument("--cache-dir", type=Path, default=Path("camus_cache"))
    ap.add_argument("--n-train-patients", type=int, default=40)
    ap.add_argument("--offset", type=int, default=70, help="disjoint from both reference-bank and SFT patients")
    ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    ap.add_argument("--kl-coef", type=float, default=0.08)
    args = ap.parse_args()
    train(args.bank, args.sft_checkpoint, args.steps, args.out, args.device, args.cache_dir, args.n_train_patients, args.offset, args.kl_coef)

0.10.1 Real output — 20 GRPO steps, LoRA on real Qwen2.5-VL, real rollouts

120 (target image, label) GRPO training examples
trainable params: 1,843,200 || all params: 3,755,913,216 || trainable%: 0.0491
step   0/20  mean_dice_reward 0.0004  loss 0.0021  kl 0.0273  n_samples 4  label=left atrium
step   1/20  mean_dice_reward 0.0003  loss 0.0000  kl 0.0000  n_samples 2  label=left ventricular myocardium
step   2/20  mean_dice_reward 0.4239  loss -0.0685  kl 0.0213  n_samples 3  label=left atrium
step   3/20  mean_dice_reward 0.0002  loss 0.0010  kl 0.0119  n_samples 4  label=left ventricular myocardium
step   4/20  mean_dice_reward 0.0003  loss 0.0040  kl 0.0498  n_samples 5  label=left atrium
step   5/20  mean_dice_reward 0.0005  loss 0.0002  kl 0.0024  n_samples 2  label=left atrium
step   6/20  mean_dice_reward 0.0372  loss 0.0049  kl 0.0612  n_samples 3  label=left ventricle
step   7/20  mean_dice_reward 0.0001  loss 0.0067  kl 0.0834  n_samples 4  label=left ventricle
step   8/20  mean_dice_reward 0.0004  loss 0.0009  kl 0.0113  n_samples 3  label=left atrium
step   9/20  mean_dice_reward 0.1115  loss -0.0289  kl 0.0446  n_samples 5  label=left ventricle
step  10/20  mean_dice_reward 0.0011  loss 0.0042  kl 0.0529  n_samples 4  label=left atrium
step  11/20  mean_dice_reward 0.0002  loss 0.0020  kl 0.0253  n_samples 5  label=left atrium
step  13/20  mean_dice_reward 0.0002  loss 0.0029  kl 0.0365  n_samples 2  label=left ventricular myocardium
step  14/20  mean_dice_reward 0.5564  loss 0.0021  kl 0.1888  n_samples 3  label=left ventricular myocardium
step  15/20  mean_dice_reward 0.0002  loss 0.0402  kl 0.5024  n_samples 4  label=left atrium
step  16/20  mean_dice_reward 0.0002  loss 0.0097  kl 0.1209  n_samples 4  label=left ventricle
step  17/20  mean_dice_reward 0.0002  loss 0.0095  kl 0.1188  n_samples 3  label=left ventricle
step  18/20  mean_dice_reward 0.0013  loss 0.0251  kl 0.2449  n_samples 2  label=left ventricle
step  19/20  mean_dice_reward 0.3854  loss -0.0812  kl 0.1549  n_samples 3  label=left ventricular myocardium
Saved grpo_curves.png + grpo_progress.gif -> grpo_out_klhigh_v2

(Step 12 is missing on purpose, not truncated — every sampled rollout for that prompt either skipped <SEG> entirely within the 16-token budget or the group came back too small to form a meaningful advantage, so the loop’s own continue guard dropped it, exactly as documented in the code above.)

GRPO reward and KL/loss curves for this run (kl_coef=0.08, real 20 steps against the SFT checkpoint captured above).
RAU segmentation prediction evolving over real GRPO steps

Honest read of this result: mean Dice reward across the 19 logged steps is ≈0.08 — mostly near-zero, with three real spikes (0.42, 0.56, 0.39) where a sampled rollout happened to land a genuinely good mask. That’s a much noisier, lower-reward run than an earlier GRPO run against a different SFT checkpoint (below) reached — and the honest reason is upstream, not in GRPO itself: this post’s SFT stage trains a single embedding row on 120 examples across 3 anatomically different structures, with an unseeded random choice of reasoning-template length each step. Two runs of that recipe can converge to policies that are both “correctly” trained by the loss curve’s standards, yet sample <SEG> in usefully different contexts under temperature-1.1 decoding — one draw gives GRPO a policy that reaches good masks often, another gives it one that mostly doesn’t know where to look yet. GRPO can only amplify signal that’s already there in the group’s variance; it can’t manufacture a good starting policy from a bad one. What does replicate across both checkpoints, reliably: the KL-vs-reference curve stays contained (never exceeds 0.50 here, vs. a straight blow-up to 2.38 at kl_coef=0.02 below) — which is the one thing this section actually set out to test.

0.10.2 KL coefficient: 0.02 vs. 0.08, controlled on one checkpoint

The run above answers “does GRPO run for real” but its low reward makes it a poor test of the KL question specifically — a policy that rarely reaches a good mask doesn’t generate the strong advantage signal needed to meaningfully stress-test the KL term either way. So the KL comparison below instead holds the SFT checkpoint fixed (an earlier, real 60-step SFT run of the same code) and varies only --kl-coef, isolating exactly one variable:

kl_coef=0.02 (script’s old default) kl_coef=0.08 (script’s new default)
Mean Dice reward (20 steps) 0.322 0.322 (same rollouts’s rewards early on — LoRA starts at identity)
KL, steps 0-6 0.02 → 0.21 (calm) 0.01 → 0.75 (spikes earlier)
KL, step 7 0.17 3.42 (sharp spike)
KL, steps 8-18 0.46 → 1.06 (climbing) 0.60 → 0.15 (pulled back down)
KL, final step 2.38 (still climbing at run end) 0.047 (settled, stable)

KL/reward curves at kl_coef=0.08 on this comparison’s checkpoint — KL spikes mid-run then is pulled back down and ends low.

KL/reward curves at kl_coef=0.02 on the same checkpoint — KL climbs through the second half of the run and is still rising, unchecked, at the final step.

Honest read: both settings reach the same reward — early rollouts are close to identical across the two runs, since a freshly-initialized LoRA adapter (B matrix zero-initialized) starts as the identity function, so the policies don’t meaningfully diverge from each other until enough gradient steps accumulate. Where they diverge is exactly where it matters: at kl_coef=0.02 the KL penalty is too weak to correct a mid-run excursion, and the policy drifts monotonically away from its SFT starting point for the rest of the run. At kl_coef=0.08 the same kind of excursion happens (it even spikes harder, to 3.42, at step 7) but the stronger penalty successfully reels it back in. Same reward, a policy that’s provably closer to its well-behaved SFT starting point by the end — that’s why 0.08 is this script’s default now, not 0.02.


0.11 Reproduce this yourself

git clone https://github.com/HasanGoni/quarto_blog_hasan
cd quarto_blog_hasan/notebooks/paper-rau-sam2
uv sync

uv run build_reference_bank.py --n-references 30 --out reference_bank.pt
uv run train_rau.py --bank reference_bank.pt --steps 60
uv run grpo_train.py --bank reference_bank.pt --sft-checkpoint train_out/sft_checkpoint.pt --steps 20

No gated models, no manual license requests — everything here (Qwen2.5-VL-3B, SAM2.1, DINOv2, CAMUS-Lite) downloads directly.

0.12 Take this into your own workflow

  • target_embedding is a real, general extension point. Any project already using SAM2 for interactive segmentation can add semantic (not just point/box) prompting the same way — project anything you can turn into a 256-d vector (a CLIP embedding, a classifier’s penultimate layer, your own VLM’s hidden state) into target_embedding and SAM2 will condition on it. Just remember the in-place-op gotcha above if you ever want gradients to flow through it.
  • The <SEG>-token trick is cheap and portable. Adding one special token, training only its embedding row plus a small downstream head, is a very small footprint way to let a VLM “hand off” to a specialist model without fine-tuning the whole VLM.
  • Optimal-transport label assignment (Section 3.2 of the paper, not reimplemented above) is worth stealing on its own — any time you’re independently classifying multiple detected regions in one image, jointly assigning labels via Sinkhorn instead of arg-maxing each one separately is a cheap, real fix for exactly the kind of hallucination dense scenes cause.
  • A GRPO KL coefficient this small a model can’t self-correct isn’t a hypothetical worry — measure it. The k3 KL estimator is cheap to log every step; if you only look at the reward curve you’ll miss a policy quietly drifting away from its SFT starting point until it’s already produced garbage. kl_coef is the one knob that trades that off directly — see the comparison below before picking a default blind.

Reuse

Citation

BibTeX citation:
@online{goni2026,
  author = {Goni, Hasan},
  title = {Reimplementing {RAU} — {VLMs} {Pointing} {SAM2} at the
    {Right} {Region,} {With} {Real} {Code}},
  date = {2026-08-28},
  langid = {en}
}
For attribution, please cite this work as:
Goni, Hasan. 2026. “Reimplementing RAU — VLMs Pointing SAM2 at the Right Region, With Real Code.” August 28.
Free email course · 7 days

Industrial Vision Foundations

AI can write the code, but someone still has to know which method fits a real inspection line. One short lesson a day for 7 days on the classical computer vision techniques that still run production systems.

Runnable code in every lesson. No spam, unsubscribe any time.

Questions, corrections or feedback?

Found a mistake, or want a topic covered next? Leave a comment below (sign in with GitHub), or write to hasanme1412@gmail.com. I read everything.