Skip to content

Commit 8de1c44

Browse files
authored
fix(gemini): render visible masks for nb2 edits
Updates the Gemini/NB2 masked edit adapter to convert alpha masks into visible black/white reference masks while keeping the formal redraw API contract unchanged.
1 parent b1385af commit 8de1c44

2 files changed

Lines changed: 36 additions & 7 deletions

File tree

‎src/vulca/providers/gemini.py‎

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import asyncio
88
import base64
9+
import io
910
import math
1011
import os
1112

@@ -66,6 +67,20 @@ def _detect_mime_type(data: bytes) -> str:
6667
return "image/png"
6768

6869

70+
def _build_visible_mask_reference(mask_bytes: bytes) -> bytes:
71+
"""Render an alpha edit mask as visible black/white guidance for Gemini."""
72+
from PIL import Image
73+
74+
with Image.open(io.BytesIO(mask_bytes)) as mask_img:
75+
alpha = mask_img.convert("RGBA").split()[-1]
76+
visible = alpha.point(lambda value: 255 if value < 128 else 0)
77+
rgb = Image.new("RGB", visible.size, (0, 0, 0))
78+
rgb.paste((255, 255, 255), mask=visible)
79+
buf = io.BytesIO()
80+
rgb.save(buf, format="PNG")
81+
return buf.getvalue()
82+
83+
6984
class GeminiImageProvider:
7085
"""Image generation via Google Gemini API.
7186
@@ -279,6 +294,7 @@ async def inpaint_with_mask(
279294
image_bytes = image_fh.read()
280295
with open(mask_path, "rb") as mask_fh:
281296
mask_bytes = mask_fh.read()
297+
visible_mask_bytes = _build_visible_mask_reference(mask_bytes)
282298

283299
if size and "x" in size:
284300
try:
@@ -295,11 +311,15 @@ async def inpaint_with_mask(
295311
aspect_ratio = _dims_to_aspect_ratio(width, height)
296312
full_prompt = (
297313
f"{prompt}\n\n"
298-
"Use the first image as the source crop. Use the second image as an "
299-
"RGBA edit mask: transparent mask pixels mark the edit region, and "
300-
"opaque mask pixels mark source context that should stay visually "
301-
"preserved. Repaint only the transparent mask pixels. Do not create "
302-
"a new scene outside the masked replacement area."
314+
"Use the first image as the source crop. Use the second image as a "
315+
"visible binary edit mask rendered from the original RGBA mask: "
316+
"white mask pixels (original transparent mask pixels) mark the edit "
317+
"region, and black mask pixels (original opaque mask pixels) mark "
318+
"source context that should stay visually preserved. Repaint only "
319+
"the white edit region. Do not create a new scene outside the masked "
320+
"replacement area. The mask image is not part of the output: do not "
321+
"draw the mask, do not copy its white or black shapes, and do not "
322+
"leave mask-colored background behind."
303323
)
304324
if tradition and tradition != "default":
305325
full_prompt += (
@@ -314,8 +334,8 @@ async def inpaint_with_mask(
314334
mime_type=_detect_mime_type(image_bytes),
315335
),
316336
types.Part.from_bytes(
317-
data=mask_bytes,
318-
mime_type=_detect_mime_type(mask_bytes),
337+
data=visible_mask_bytes,
338+
mime_type="image/png",
319339
),
320340
full_prompt,
321341
]

‎tests/test_gemini_image_size.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,17 @@ def __init__(self, api_key):
202202
assert recorded["model"] == "gemini-3.1-flash-image-preview"
203203
assert recorded["contents"][0].mime_type == "image/png"
204204
assert recorded["contents"][1].mime_type == "image/png"
205+
visible_mask = Image.open(io.BytesIO(recorded["contents"][1].data)).convert(
206+
"RGB"
207+
)
208+
assert visible_mask.getpixel((8, 6)) == (255, 255, 255)
209+
assert visible_mask.getpixel((0, 0)) == (0, 0, 0)
205210
assert "transparent mask pixels" in recorded["contents"][2]
206211
assert "opaque mask pixels" in recorded["contents"][2]
212+
assert "white mask pixels" in recorded["contents"][2]
213+
assert "black mask pixels" in recorded["contents"][2]
214+
assert "do not draw the mask" in recorded["contents"][2].lower()
215+
assert "not part of the output" in recorded["contents"][2].lower()
207216
assert "paint one compact yellow flower head" in recorded["contents"][2]
208217
assert result.mime == "image/png"
209218
assert base64.b64decode(result.image_b64).startswith(b"\x89PNG")

0 commit comments

Comments
 (0)