Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions agentlightning/verl/rollout_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,10 @@ def _load_pil_image(url: str) -> Any:

if url.startswith("data:"):
_, _, payload = url.partition(",")
return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
if url.startswith("file://"):
return Image.open(url2pathname(url.removeprefix("file:"))).convert("RGB")
if url.startswith(("http://", "https://")):
image_source = io.BytesIO(base64.b64decode(payload))
elif url.startswith("file://"):
image_source = url2pathname(url.removeprefix("file:"))
elif url.startswith(("http://", "https://")):
# [multimodal-patch] Remote images are fetched from the trainer process with
# content-type and size validation.
import httpx
Expand All @@ -313,8 +313,17 @@ def _load_pil_image(url: str) -> Any:
max_bytes = 50 * 1024 * 1024
if len(response.content) > max_bytes:
raise ValueError(f"[multimodal-patch] remote image exceeds {max_bytes} bytes")
return Image.open(io.BytesIO(response.content)).convert("RGB")
raise ValueError(f"[multimodal-patch] unsupported image url scheme: {url[:64]}")
image_source = io.BytesIO(response.content)
else:
raise ValueError(f"[multimodal-patch] unsupported image url scheme: {url[:64]}")

with Image.open(image_source) as image:
if image.mode == "RGBA":
# Match vLLM's default image decoding so rollout and training see the same pixels.
rgb_image = Image.new("RGB", image.size, (255, 255, 255))
rgb_image.paste(image, mask=image.getchannel("A"))
return rgb_image
return image.convert("RGB")


def _build_multi_modal_inputs(processor: Any, image_urls: list[str]) -> tuple[dict[str, Any], bool]:
Expand Down
2 changes: 2 additions & 0 deletions docs/80-example-multimodal-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The dataset is generated with PIL at startup — there is nothing to download:

For images already stored on the trainer host, construct each `image_url` with `Path("images/frame 1.png").resolve().as_uri()` (from `pathlib`). Local file URIs preserve spaces, Unicode, and literal percent characters in filenames.

RGBA images are composited onto a white background before training, matching vLLM's default image decoder. If your rollout server uses a custom `rgba_background_color`, pre-composite the images to RGB with that background before sending them so rollout and training use identical pixels.

Any mrope VLM supported by verl works; pass `--model` to change the default model.

## Training
Expand Down
38 changes: 38 additions & 0 deletions tests/verl/test_rollout_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1087,3 +1087,41 @@ def raise_for_status(self) -> None:

assert image.size == (2, 2)
assert image.mode == "RGB"


@pytest.mark.parametrize("scheme", ["data", "file", "https"])
@pytest.mark.parametrize("mode", ["RGB", "RGBA"])
def test_load_pil_image_matches_rollout_rgb_conversion(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, scheme: str, mode: str
) -> None:
import httpx
from PIL import Image

source = Image.new(mode, (3, 1))
pixels = [(10, 20, 30), (10, 20, 30), (10, 20, 30)]
source.putdata(
[(*rgb, alpha) for rgb, alpha in zip(pixels, [0, 128, 255], strict=True)] if mode == "RGBA" else pixels
)
buffer = io.BytesIO()
source.save(buffer, format="PNG")
payload = buffer.getvalue()
if scheme == "data":
url = "data:image/png;base64," + base64.b64encode(payload).decode()
elif scheme == "file":
path = tmp_path / "transparent image.png"
path.write_bytes(payload)
url = path.as_uri()
else:
url = "https://example.com/transparent.png"
response = httpx.Response(
200, content=payload, headers={"content-type": "image/png"}, request=httpx.Request("GET", url)
)
monkeypatch.setattr(httpx, "get", lambda *args, **kwargs: response)

image = rollout_adapter_module._load_pil_image(url)

# vLLM composites RGBA onto white before producing rollout vision inputs.
expected = [(255, 255, 255), (132, 137, 142), (10, 20, 30)] if mode == "RGBA" else pixels
assert image.mode == "RGB"
assert image.size == (3, 1)
assert [image.getpixel((x, 0)) for x in range(3)] == expected