From c7524e1fcb9cfe90d027da1440a47c251aba6bd7 Mon Sep 17 00:00:00 2001 From: Chao Wang <132262609+chaoworks@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:48:33 +0800 Subject: [PATCH] fix(verl): match rollout alpha compositing for RGBA images Signed-off-by: Chao Wang <132262609+chaoworks@users.noreply.github.com> --- agentlightning/verl/rollout_adapter.py | 21 ++++++++++---- docs/80-example-multimodal-qa.md | 2 ++ tests/verl/test_rollout_adapter.py | 38 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/agentlightning/verl/rollout_adapter.py b/agentlightning/verl/rollout_adapter.py index 89b23d03c..c16d46bee 100644 --- a/agentlightning/verl/rollout_adapter.py +++ b/agentlightning/verl/rollout_adapter.py @@ -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 @@ -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]: diff --git a/docs/80-example-multimodal-qa.md b/docs/80-example-multimodal-qa.md index eb6ecb970..3177f5b15 100644 --- a/docs/80-example-multimodal-qa.md +++ b/docs/80-example-multimodal-qa.md @@ -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 diff --git a/tests/verl/test_rollout_adapter.py b/tests/verl/test_rollout_adapter.py index abd914615..479e11c0e 100644 --- a/tests/verl/test_rollout_adapter.py +++ b/tests/verl/test_rollout_adapter.py @@ -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