"""Inputs, prompts and output parsing for the FLORA rewarder. One model answers three prompts: * terminal: one 1-5 score for a whole episode (1 = complete failure, 5 = complete success); * dense: one 1-3 action-quality score per 0.5 s (1 = failure, 2 = suboptimal, 3 = normal), with a short rationale for every non-normal span; * rubric: a score for every step of a user-defined rubric, after a thinking trace. This module has no inference backend. It builds the chat messages and the exact video frames the model expects, and turns the generated text back into rewards. """ from __future__ import annotations from collections import Counter from collections.abc import Mapping, Sequence from dataclasses import dataclass from functools import cache import json import math from pathlib import Path import re from typing import Any import numpy as np from PIL import Image VIDEO_PLACEHOLDER = "__video__" # --------------------------------------------------------------------------- # Video reading # --------------------------------------------------------------------------- FPS_RELATIVE_TOLERANCE = 0.01 @dataclass(frozen=True) class VideoInfo: path: str fps: float num_frames: int width: int height: int @property def duration_s(self) -> float: return self.num_frames / self.fps def probe_video(path: str | Path) -> VideoInfo: """Frame rate, frame count and size of the first video stream.""" import av with av.open(str(path)) as container: stream = container.streams.video[0] rate = stream.average_rate or stream.guessed_rate if not rate or float(rate) <= 0: raise ValueError(f"{path}: the video has no usable frame rate") width, height = int(stream.codec_context.width), int(stream.codec_context.height) frames = sum(1 for packet in container.demux(stream) if packet.size) if frames < 1: raise ValueError(f"{path}: the video has no frames") return VideoInfo(str(path), float(rate), frames, width, height) def read_frames( path: str | Path, indices: Sequence[int], size: tuple[int, int] | None = None ) -> dict[int, np.ndarray]: """Decode the requested frame indices as RGB uint8 arrays, optionally resized. Resizing uses bilinear interpolation to ``size`` (width, height), with no crop or padding. """ import av wanted = sorted({int(i) for i in indices}) out: dict[int, np.ndarray] = {} if not wanted: return out with av.open(str(path)) as container: stream = container.streams.video[0] stream.thread_type = "AUTO" pending = iter(wanted) target = next(pending) for position, frame in enumerate(container.decode(stream)): if position != target: continue image = frame.to_image() if size is not None and image.size != tuple(size): image = image.resize(tuple(size), resample=Image.Resampling.BILINEAR) out[position] = np.asarray(image.convert("RGB"), dtype=np.uint8) target = next(pending, None) if target is None: break if len(out) != len(wanted): raise ValueError(f"{path}: decoded fewer frames than its container reports") return out def check_synchronized(videos: Sequence[VideoInfo]) -> tuple[float, int]: """Camera views must share one frame clock. Returns (fps, frame count).""" if not videos: raise ValueError("at least one camera video is required") fps = videos[0].fps for video in videos[1:]: if abs(video.fps - fps) > FPS_RELATIVE_TOLERANCE * fps: raise ValueError(f"camera videos have different frame rates: {fps} vs {video.fps}") counts = [video.num_frames for video in videos] if max(counts) - min(counts) > max(1, round(0.01 * max(counts))): raise ValueError(f"camera videos have different lengths: {sorted(set(counts))} frames") return fps, min(counts) # --------------------------------------------------------------------------- # Multi-view collage (terminal and dense prompts) # --------------------------------------------------------------------------- # All camera views of one time step are tiled into one frame: one view as is, two or three # views side by side, four views as a 2 x 2 grid, in the order given. Every view is resized # to a 32-pixel-aligned cell of about 100k pixels, with no crop, padding or gutter. ALIGN = 32 TARGET_PIXELS_PER_VIEW = 100_000 MAX_VISUAL_TOKENS = 14_336 MAX_VIEWS = 4 def _aspect_distortion(source_width: int, source_height: int, width: int, height: int) -> float: return 100.0 * abs((width / height) / (source_width / source_height) - 1.0) def _cell_loss(source: tuple[int, int], width: int, height: int) -> float: distortion = _aspect_distortion(*source, width, height) area_error = abs(math.log((width * height) / TARGET_PIXELS_PER_VIEW)) over_limit = max(0.0, distortion - 4.0) return (distortion / 4.0) ** 2 + (area_error / 0.32) ** 2 + 10_000.0 * over_limit**2 def _candidate_widths(source: tuple[int, int], height: int) -> list[int]: center = round(source[0] / source[1] * height / ALIGN) * ALIGN return sorted( { center + offset * ALIGN for offset in range(-4, 5) if 2 * ALIGN <= center + offset * ALIGN <= 32 * ALIGN } ) def _heights() -> range: return range(4 * ALIGN, 17 * ALIGN, ALIGN) def _tokens(width: int, height: int) -> int: return 32 * (width // ALIGN) * (height // ALIGN) def _single_cell(source: tuple[int, int]) -> tuple[int, int]: candidates = [ (_cell_loss(source, width, height), width, height) for height in _heights() for width in _candidate_widths(source, height) ] _, width, height = min(candidates) return width, height def _row_cells(sources: Sequence[tuple[int, int]]): candidates = [] for height in _heights(): widths = [ min(_candidate_widths(s, height), key=lambda w, s=s: _cell_loss(s, w, height)) for s in sources ] loss = sum(_cell_loss(s, w, height) for s, w in zip(sources, widths)) if _tokens(sum(widths), height) <= MAX_VISUAL_TOKENS: candidates.append((loss, [(w, height) for w in widths], sum(widths), height)) if not candidates: raise ValueError("no side-by-side layout fits the visual token budget") _, cells, total_width, total_height = min(candidates) return cells, total_width, total_height def _row_options(first: tuple[int, int], second: tuple[int, int]): options: dict[int, tuple[float, int, int, int]] = {} for height in _heights(): for w1 in _candidate_widths(first, height): for w2 in _candidate_widths(second, height): loss = _cell_loss(first, w1, height) + _cell_loss(second, w2, height) current = options.get(w1 + w2) if current is None or loss < current[0]: options[w1 + w2] = (loss, height, w1, w2) return options def _grid_cells(sources: Sequence[tuple[int, int]]): top_options = _row_options(sources[0], sources[1]) bottom_options = _row_options(sources[2], sources[3]) candidates = [] for total_width in top_options.keys() & bottom_options.keys(): top, bottom = top_options[total_width], bottom_options[total_width] if _tokens(total_width, top[1] + bottom[1]) <= MAX_VISUAL_TOKENS: candidates.append((top[0] + bottom[0], total_width, top, bottom)) if not candidates: raise ValueError("no 2 x 2 layout fits the visual token budget") _, total_width, top, bottom = min(candidates) (_, top_h, tl_w, tr_w), (_, bottom_h, bl_w, br_w) = top, bottom rectangles = [ (0, 0, tl_w, top_h), (tl_w, 0, tr_w, top_h), (0, top_h, bl_w, bottom_h), (bl_w, top_h, br_w, bottom_h), ] return rectangles, total_width, top_h + bottom_h @cache def plan_collage(sources: tuple[tuple[int, int], ...]): """Return ``(rectangles, width, height)``: one ``(x, y, w, h)`` cell per view, in order.""" if not 1 <= len(sources) <= MAX_VIEWS: raise ValueError(f"1 to {MAX_VIEWS} camera views are supported, got {len(sources)}") if len(sources) == 1: width, height = _single_cell(sources[0]) return ((0, 0, width, height),), width, height if len(sources) in (2, 3): cells, width, height = _row_cells(sources) rectangles, x = [], 0 for w, h in cells: rectangles.append((x, 0, w, h)) x += w return tuple(rectangles), width, height rectangles, width, height = _grid_cells(sources) return tuple(rectangles), width, height class Collage: """Synchronized camera videos composed into one collage video.""" def __init__(self, paths: Sequence[str | Path]): if not paths: raise ValueError("at least one camera video is required") self.videos = [probe_video(path) for path in paths] self.fps, self.num_frames = check_synchronized(self.videos) sources = tuple((v.width, v.height) for v in self.videos) self.rectangles, self.width, self.height = plan_collage(sources) self._cache: dict[int, np.ndarray] = {} @property def duration_s(self) -> float: return self.num_frames / self.fps def release(self) -> None: """Drop decoded frames.""" self._cache.clear() def frames(self, indices: Sequence[int]) -> np.ndarray: """``[T, H, W, 3]`` uint8 collage frames for source frame ``indices``.""" missing = sorted({int(i) for i in indices} - set(self._cache)) if missing: per_view = [ read_frames(video.path, missing, size=(w, h)) for video, (_, _, w, h) in zip(self.videos, self.rectangles) ] for index in missing: canvas = np.zeros((self.height, self.width, 3), dtype=np.uint8) for frames, (x, y, w, h) in zip(per_view, self.rectangles): canvas[y : y + h, x : x + w] = frames[index] self._cache[index] = canvas return np.stack([self._cache[int(i)] for i in indices]) def video_metadata(indices: Sequence[int], fps: float, num_frames: int) -> dict[str, Any]: """Frame-clock metadata so the model's timestamps are absolute episode seconds.""" return { "total_num_frames": int(num_frames), "fps": float(fps), "frames_indices": [int(i) for i in indices], "duration": float(num_frames) / float(fps), "do_sample_frames": False, } def _messages(system: str, user: str) -> list[dict[str, Any]]: before, marker, after = user.partition("