# processing_rynn_value_lang.py import torch from typing import Union, Optional, List from einops import rearrange from PIL.Image import Image from transformers.feature_extraction_utils import BatchFeature from transformers.image_utils import ImageInput from transformers.models.qwen3_vl.processing_qwen3_vl import ( Qwen3VLProcessor, Qwen3VLProcessorKwargs, ) from transformers.processing_utils import Unpack from transformers.tokenization_utils_base import PreTokenizedInput, TextInput from .conversations import ( ConversationBuilder, InterleavedHistoryConversationBuilder, build_conversation_builder, ) DEFAULT_CONVERSATION_STYLE = InterleavedHistoryConversationBuilder.name class RynnValueLangProcessor(Qwen3VLProcessor): attributes = ["image_processor", "tokenizer"] image_processor_class = "AutoImageProcessor" tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast") def __init__( self, image_processor=None, tokenizer=None, video_processor=None, chat_template=None, use_meta=False, conversation_style: str = DEFAULT_CONVERSATION_STYLE, value_token_repeat: int = 1, relative_value_token_repeat: int = 1, **kwargs, ): super().__init__(image_processor=image_processor, tokenizer=tokenizer, video_processor=video_processor, chat_template=chat_template, **kwargs) # Whether to inject robot type / camera position meta info into prompts. # Decided at processor construction and persisted in processor_config.json; # not threaded through individual process_* calls. self.use_meta = use_meta # Selects the ConversationBuilder that owns the prompt layout. Persisted # in processor_config.json so train and inference build identical prompts # without the caller having to re-specify. self.conversation_style = conversation_style # Number of consecutive tokens emitted per prediction slot. # Persisted so the model can pool the R tokens back to one prediction. if value_token_repeat < 1: raise ValueError( f"value_token_repeat must be >= 1, got {value_token_repeat}" ) self.value_token_repeat = int(value_token_repeat) # Same for : R copies per slot, pooled by the model. if relative_value_token_repeat < 1: raise ValueError( f"relative_value_token_repeat must be >= 1, got {relative_value_token_repeat}" ) self.relative_value_token_repeat = int(relative_value_token_repeat) # Build eagerly so callers can rely on `self.conversation_builder` from # __init__ onwards. After mutating tokens or toggling use_meta / # conversation_style, callers must invoke # ``refresh_conversation_builder()`` to pick up the change — see # ``add_special_tokens_and_resize``. self.conversation_builder: ConversationBuilder = self._build_conversation_builder() @classmethod def from_qwen3vl(cls, pretrained_model_name_or_path: str, **kwargs): """ Build a RynnValueLangProcessor from a Qwen3VL checkpoint. """ return cls.from_pretrained(pretrained_model_name_or_path, **kwargs) @property def value_token(self): return "" @property def value_token_id(self): return self.tokenizer.convert_tokens_to_ids(self.value_token) @property def relative_value_token(self): return "" if "" in self.tokenizer.get_vocab() else None @property def relative_value_token_id(self): token = self.relative_value_token return None if token is None else self.tokenizer.convert_tokens_to_ids(token) def __call__( self, images: ImageInput, text: Optional[Union[TextInput, PreTokenizedInput, List[TextInput], List[PreTokenizedInput]]] = None, **kwargs: Unpack[Qwen3VLProcessorKwargs], ) -> BatchFeature: return super().__call__( images=images, text=text, **kwargs, **{ "return_tensors": "pt", }, ) def _build_conversation_builder(self) -> ConversationBuilder: """Snapshot the processor's current state into a ConversationBuilder.""" return build_conversation_builder( self.conversation_style, value_token=self.value_token, relative_value_token=self.relative_value_token, use_meta=self.use_meta, value_token_repeat=self.value_token_repeat, relative_value_token_repeat=self.relative_value_token_repeat, ) def refresh_conversation_builder(self) -> ConversationBuilder: """Rebuild ``self.conversation_builder`` from the current processor state. Callers should invoke this whenever they mutate any input the builder depends on (e.g. ``use_meta``, ``conversation_style``, or any of the special tokens on the tokenizer). """ self.conversation_builder = self._build_conversation_builder() return self.conversation_builder @staticmethod def _frame_target_values( goal_timestamp: torch.Tensor, frame_timestamps: torch.Tensor, device: torch.device, ) -> torch.Tensor: """Return per-frame targets as [1, N].""" ts = frame_timestamps.to(device).reshape(-1) goal_ts = goal_timestamp.to(device).reshape(1) return (goal_ts - ts).float().unsqueeze(0) @staticmethod def _anchor_target_value( goal_timestamp: torch.Tensor, frame_timestamps: torch.Tensor, device: torch.device, ) -> torch.Tensor: """Return the single anchor (last-frame) target as [1, 1].""" ts = frame_timestamps.to(device).reshape(-1) goal_ts = goal_timestamp.to(device).reshape(1) return (goal_ts - ts[-1:]).float().unsqueeze(0) def _progress_targets( self, goal_timestamp: torch.Tensor, frame_timestamps: torch.Tensor, device: torch.device, value_count: int, ) -> torch.Tensor: """Targets aligned with the builder's token count. ``value_count == 1`` -> anchor (last-frame) remaining time, [1, 1]. Otherwise per-frame remaining times, [1, value_count]; ``value_count`` must equal the number of input frames so the targets align with the interleaved tokens. Frames past the goal saturate at 0 — the progress head represents non-negative time-to-done. """ if value_count == 1: target = self._anchor_target_value(goal_timestamp, frame_timestamps, device) else: target = self._frame_target_values(goal_timestamp, frame_timestamps, device) if target.shape[1] != value_count: raise ValueError( f"Conversation builder expects {value_count} targets but " f"received {target.shape[1]} frame timestamps." ) return target.clamp_min(0) def process_history( self, instruction: str, description: Optional[str], images: List[Image], frame_timestamps: torch.Tensor, goal_timestamp: torch.Tensor, success: Optional[bool] = None, fusion: Optional[bool] = None, robot_description: Optional[str] = None, camera_description: Optional[str] = None, ) -> BatchFeature: """Process a history sample. Layout (``interleaved_history``): top-level instruction, frames with ```` between them, optional description sentence (input only, no LM supervision), and per-frame ```` tokens. Relative-value targets are derived from ``frame_timestamps`` as signed consecutive deltas of length ``num_frames - 1``. Token positions for the prediction heads are inferred inside the model from ``input_ids`` + registered token IDs, so no ``*_logits_to_keep`` tensors are emitted here. """ builder = self.conversation_builder num_frames = len(images) value_count = builder.progress_value_count(num_frames) fusion_flag = 0 if fusion is None else int(bool(fusion)) match_answer = "No" if fusion_flag else "Yes" if success is None: success_answer = None else: success_answer = "Yes" if success else "No" content = builder.build_progress( instruction=instruction, description=description, num_frames=num_frames, robot_description=robot_description, camera_description=camera_description, match_answer=match_answer, success_answer=success_answer, ) conversation = [{"role": "user", "content": content}] text = self.apply_chat_template(conversation) mini_outputs = self.__call__(text=text, images=images) input_ids = mini_outputs["input_ids"] target_values = self._progress_targets( goal_timestamp, frame_timestamps, device=input_ids.device, value_count=value_count, ) mini_outputs["value"] = target_values mini_outputs["value_fusion_mask"] = torch.full_like( target_values, fill_value=fusion_flag, dtype=torch.long ) ts = frame_timestamps.reshape(-1).to(input_ids.device).float() mini_outputs["relative_value"] = (ts[1:] - ts[:-1]).unsqueeze(0) # Language loss labels: supervise the "Analysis:" span with causal LM # loss. Always emitted (all -100 if marker not found) so batches with # mixed samples concat cleanly. mini_outputs["labels"] = self._build_history_labels(input_ids) return mini_outputs def _build_history_labels( self, input_ids: torch.Tensor, ) -> torch.Tensor: """Create causal LM labels for the "Analysis:" span in the history layout. Everything after "Analysis:" is supervised with causal LM loss. Since "Analysis:" is emitted as a standalone text block, its token sequence is deterministic — we match it directly in input_ids. Raises ValueError if the marker isn't found (indicates a builder bug). """ B, L = input_ids.shape marker_ids = self.tokenizer.encode("Analysis: \n", add_special_tokens=False) marker_len = len(marker_ids) marker_t = torch.tensor(marker_ids, device=input_ids.device) labels = torch.full_like(input_ids, -100) for b in range(B): seq = input_ids[b] found = False for i in range(L - marker_len, -1, -1): if torch.equal(seq[i:i + marker_len], marker_t): content_start = i + marker_len if content_start < L: labels[b, content_start:] = seq[content_start:] found = True break if not found: raise ValueError( f"'Analysis:' marker not found in sample {b}. " "The conversation builder must emit an 'Analysis:' block." ) return labels def process_episode( self, instruction: str, images: List[Image], robot_description: Optional[str] = None, camera_description: Optional[str] = None, ) -> BatchFeature: """Process one episode in progress mode (inference). All images passed in are used directly as prediction frames. The output is shaped as a single sample: ``input_ids``/``attention_mask`` are ``(1, L)`` and ``pixel_values``/``image_grid_thw`` are ``(1, k, D)`` with ``k`` equal to the number of input images. """ if len(images) == 0: raise ValueError("process_episode requires at least one prediction image.") k = len(images) builder = self.conversation_builder content = builder.build_progress( instruction=instruction, description=None, num_frames=k, robot_description=robot_description, camera_description=camera_description, is_inference=True, ) conv = [{"role": "user", "content": content}] text = self.apply_chat_template(conv) merged = self.__call__(text=text, images=images) eos_token_id = self.tokenizer.convert_tokens_to_ids("<|im_end|>") input_ids = merged["input_ids"] eos_positions = (input_ids[0] == eos_token_id).nonzero(as_tuple=True)[0] if len(eos_positions) > 0: input_ids = input_ids[:, :eos_positions[-1]] merged["input_ids"] = input_ids merged["attention_mask"] = merged["attention_mask"][:, :input_ids.shape[-1]] merged["pixel_values"] = rearrange(merged["pixel_values"], "(b t) d -> b t d", b=1) merged["image_grid_thw"] = rearrange(merged["image_grid_thw"], "(b t) d -> b t d", b=1) return merged