fix overlays
This commit is contained in:
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
Binary file not shown.
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,92 @@ def generate_texture_image(props, width, height):
|
||||
pass
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Determine resampling filters compatible with current Pillow version
|
||||
resampling_module = getattr(Image, 'Resampling', None)
|
||||
lanczos_filter = getattr(resampling_module, 'LANCZOS', getattr(Image, 'LANCZOS', Image.BICUBIC))
|
||||
bicubic_filter = getattr(resampling_module, 'BICUBIC', getattr(Image, 'BICUBIC', Image.BILINEAR))
|
||||
|
||||
def _iter_enabled_overlays():
|
||||
"""Yield (overlay, image_path) tuples for overlays that should be applied."""
|
||||
overlays = getattr(props, 'image_overlays', [])
|
||||
try:
|
||||
overlay_list = list(overlays)
|
||||
except TypeError:
|
||||
overlay_list = []
|
||||
for overlay in overlay_list:
|
||||
if not getattr(overlay, 'enabled', True):
|
||||
continue
|
||||
image_path = getattr(overlay, 'image_path', '')
|
||||
if not image_path:
|
||||
continue
|
||||
yield overlay, image_path
|
||||
|
||||
def _calculate_overlay_position(overlay, overlay_size):
|
||||
"""Calculate top-left pixel position for an overlay image."""
|
||||
overlay_width, overlay_height = overlay_size
|
||||
canvas_width = width
|
||||
canvas_height = height
|
||||
mode = getattr(overlay, 'positioning_mode', 'ABSOLUTE') or 'ABSOLUTE'
|
||||
|
||||
x_norm = float(getattr(overlay, 'x_position', 0.5) or 0.5)
|
||||
y_norm = float(getattr(overlay, 'y_position', 0.5) or 0.5)
|
||||
center_x = x_norm * canvas_width
|
||||
center_y = y_norm * canvas_height
|
||||
|
||||
spacing_ratio = max(0.0, float(getattr(overlay, 'text_spacing', 0.0) or 0.0)) / 100.0
|
||||
|
||||
if mode == 'APPEND':
|
||||
center_x = (canvas_width * 0.5) + (spacing_ratio * canvas_width) + (overlay_width / 2.0)
|
||||
elif mode == 'PREPEND':
|
||||
center_x = (canvas_width * 0.5) - (spacing_ratio * canvas_width) - (overlay_width / 2.0)
|
||||
|
||||
x = int(round(center_x - overlay_width / 2.0))
|
||||
y = int(round(center_y - overlay_height / 2.0))
|
||||
|
||||
max_x = max(0, canvas_width - overlay_width)
|
||||
max_y = max(0, canvas_height - overlay_height)
|
||||
x = max(0, min(max_x, x))
|
||||
y = max(0, min(max_y, y))
|
||||
return x, y
|
||||
|
||||
def _apply_image_overlays(pil_canvas):
|
||||
"""Composite enabled image overlays onto the base canvas."""
|
||||
overlays_to_apply = sorted(
|
||||
_iter_enabled_overlays(),
|
||||
key=lambda item: getattr(item[0], 'z_index', 0)
|
||||
)
|
||||
|
||||
for overlay, image_path in overlays_to_apply:
|
||||
try:
|
||||
with Image.open(image_path) as raw_overlay:
|
||||
overlay_img = raw_overlay.convert('RGBA')
|
||||
except Exception as overlay_error:
|
||||
print(f"WARNING: Failed to load overlay image '{image_path}': {overlay_error}")
|
||||
continue
|
||||
|
||||
scale = float(getattr(overlay, 'scale', 1.0) or 1.0)
|
||||
if scale <= 0:
|
||||
continue
|
||||
if scale != 1.0:
|
||||
new_size = (
|
||||
max(1, int(round(overlay_img.width * scale))),
|
||||
max(1, int(round(overlay_img.height * scale)))
|
||||
)
|
||||
if new_size != overlay_img.size:
|
||||
overlay_img = overlay_img.resize(new_size, lanczos_filter)
|
||||
|
||||
rotation = float(getattr(overlay, 'rotation', 0.0) or 0.0)
|
||||
if rotation % 360:
|
||||
overlay_img = overlay_img.rotate(-rotation, expand=True, resample=bicubic_filter)
|
||||
|
||||
x_pos, y_pos = _calculate_overlay_position(overlay, overlay_img.size)
|
||||
|
||||
overlay_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
||||
overlay_layer.paste(overlay_img, (x_pos, y_pos), overlay_img)
|
||||
pil_canvas = Image.alpha_composite(pil_canvas, overlay_layer)
|
||||
|
||||
return pil_canvas
|
||||
|
||||
# Create PIL image with transparent background
|
||||
if props.background_transparent:
|
||||
pass
|
||||
@@ -242,6 +328,9 @@ def generate_texture_image(props, width, height):
|
||||
draw.text((x, y), full_text, font=font, fill=text_color)
|
||||
else:
|
||||
pass
|
||||
|
||||
# Apply any configured image overlays before converting to Blender pixels
|
||||
pil_img = _apply_image_overlays(pil_img)
|
||||
|
||||
# Convert PIL image to Blender format (flip vertically to fix mirroring)
|
||||
pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) # Fix mirroring issue
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -94,6 +94,7 @@ def create_mock_props(**kwargs):
|
||||
props = MagicMock()
|
||||
|
||||
# Set default values
|
||||
props.image_overlays = kwargs.get('image_overlays', [])
|
||||
props.text = kwargs.get('text', '')
|
||||
props.prepend_text = kwargs.get('prepend_text', '')
|
||||
props.append_text = kwargs.get('append_text', '')
|
||||
@@ -417,12 +418,204 @@ class TestGenerateTextureImage:
|
||||
|
||||
# Call function
|
||||
result, normal_map = generate_texture_image(props, 256, 512)
|
||||
|
||||
# Assertions
|
||||
assert result is not None
|
||||
# Verify multiple draw.text calls for vertical layout
|
||||
assert mock_draw.text.call_count >= 3 # One per text part
|
||||
|
||||
|
||||
def test_applies_absolute_overlay_to_texture(self, tmp_path):
|
||||
"""Image overlays should be composited onto the generated texture."""
|
||||
Image = pytest.importorskip("PIL.Image")
|
||||
with patch('src.core.generation_engine.bpy') as mock_bpy:
|
||||
width = height = 64
|
||||
overlay_path = tmp_path / "overlay.png"
|
||||
# Create a solid red overlay image
|
||||
overlay_source = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
|
||||
overlay_source.save(overlay_path)
|
||||
|
||||
mock_blender_image = MagicMock()
|
||||
mock_blender_image.pixels = [0.0] * (width * height * 4)
|
||||
mock_bpy.data.images.new.return_value = mock_blender_image
|
||||
mock_bpy.data.images.__contains__.return_value = False
|
||||
|
||||
props = create_mock_props(
|
||||
text="",
|
||||
background_transparent=False,
|
||||
background_color=(1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
|
||||
overlay = MagicMock()
|
||||
overlay.enabled = True
|
||||
overlay.image_path = str(overlay_path)
|
||||
overlay.positioning_mode = 'ABSOLUTE'
|
||||
overlay.x_position = 0.5
|
||||
overlay.y_position = 0.5
|
||||
overlay.scale = 1.0
|
||||
overlay.rotation = 0.0
|
||||
overlay.z_index = 0
|
||||
overlay.text_spacing = 0.0
|
||||
overlay.image_spacing = 0.0
|
||||
props.image_overlays = [overlay]
|
||||
|
||||
result, normal_map = generate_texture_image(props, width, height)
|
||||
|
||||
assert result is mock_blender_image
|
||||
assert normal_map is None
|
||||
|
||||
center_index = ((height // 2) * width + (width // 2)) * 4
|
||||
center_pixel = mock_blender_image.pixels[center_index:center_index + 4]
|
||||
# Center pixel should now be red from the overlay
|
||||
assert center_pixel[0] == pytest.approx(1.0, rel=1e-3)
|
||||
assert center_pixel[1] == pytest.approx(0.0, abs=1e-3)
|
||||
assert center_pixel[2] == pytest.approx(0.0, abs=1e-3)
|
||||
assert center_pixel[3] == pytest.approx(1.0, rel=1e-3)
|
||||
|
||||
def test_skips_overlays_without_valid_path(self, tmp_path):
|
||||
"""Overlays without a valid image path should be ignored."""
|
||||
pytest.importorskip("PIL.Image")
|
||||
with patch('PIL.Image.open') as mock_image_open, \
|
||||
patch('src.core.generation_engine.bpy') as mock_bpy:
|
||||
width = height = 32
|
||||
|
||||
mock_blender_image = MagicMock()
|
||||
mock_blender_image.pixels = [0.0] * (width * height * 4)
|
||||
mock_bpy.data.images.new.return_value = mock_blender_image
|
||||
mock_bpy.data.images.__contains__.return_value = False
|
||||
|
||||
props = create_mock_props(
|
||||
text="",
|
||||
background_transparent=True
|
||||
)
|
||||
|
||||
overlay = MagicMock()
|
||||
overlay.enabled = True
|
||||
overlay.image_path = ""
|
||||
overlay.positioning_mode = 'ABSOLUTE'
|
||||
overlay.x_position = 0.5
|
||||
overlay.y_position = 0.5
|
||||
overlay.scale = 1.0
|
||||
overlay.rotation = 0.0
|
||||
overlay.z_index = 0
|
||||
overlay.text_spacing = 0.0
|
||||
overlay.image_spacing = 0.0
|
||||
props.image_overlays = [overlay]
|
||||
|
||||
result, normal_map = generate_texture_image(props, width, height)
|
||||
|
||||
assert result is mock_blender_image
|
||||
assert normal_map is None
|
||||
mock_image_open.assert_not_called()
|
||||
|
||||
def test_append_overlay_positions_to_right(self, tmp_path):
|
||||
"""Append overlays should be positioned to the right side with spacing applied."""
|
||||
Image = pytest.importorskip("PIL.Image")
|
||||
with patch('src.core.generation_engine.bpy') as mock_bpy:
|
||||
width = height = 64
|
||||
overlay_path = tmp_path / "append.png"
|
||||
overlay_source = Image.new("RGBA", (10, 10), (0, 255, 0, 255))
|
||||
overlay_source.save(overlay_path)
|
||||
|
||||
mock_blender_image = MagicMock()
|
||||
mock_blender_image.pixels = [0.0] * (width * height * 4)
|
||||
mock_bpy.data.images.new.return_value = mock_blender_image
|
||||
mock_bpy.data.images.__contains__.return_value = False
|
||||
|
||||
props = create_mock_props(
|
||||
text="",
|
||||
background_transparent=False,
|
||||
background_color=(1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
|
||||
overlay = MagicMock()
|
||||
overlay.enabled = True
|
||||
overlay.image_path = str(overlay_path)
|
||||
overlay.positioning_mode = 'APPEND'
|
||||
overlay.text_spacing = 10.0 # push further right
|
||||
overlay.image_spacing = 0.0
|
||||
overlay.scale = 1.0
|
||||
overlay.rotation = 0.0
|
||||
overlay.z_index = 0
|
||||
overlay.x_position = 0.5
|
||||
overlay.y_position = 0.5
|
||||
props.image_overlays = [overlay]
|
||||
|
||||
result, normal_map = generate_texture_image(props, width, height)
|
||||
|
||||
assert result is mock_blender_image
|
||||
assert normal_map is None
|
||||
|
||||
spacing_ratio = overlay.text_spacing / 100.0
|
||||
center_x = (width * 0.5) + (spacing_ratio * width) + (overlay_source.width / 2.0)
|
||||
expected_x = int(round(center_x - overlay_source.width / 2.0))
|
||||
expected_y = int(round((height * overlay.y_position) - (overlay_source.height / 2.0)))
|
||||
expected_y = max(0, min(height - overlay_source.height, expected_y))
|
||||
|
||||
sample_row = expected_y + overlay_source.height // 2
|
||||
green_columns = []
|
||||
for x in range(width):
|
||||
idx = (sample_row * width + x) * 4
|
||||
pixel = mock_blender_image.pixels[idx:idx + 4]
|
||||
if pixel and pixel[1] >= 0.95 and pixel[0] <= 0.1 and pixel[2] <= 0.1:
|
||||
green_columns.append(x)
|
||||
|
||||
assert green_columns, "Append overlay pixels not found"
|
||||
assert max(green_columns) >= expected_x + overlay_source.width - 2
|
||||
assert min(green_columns) >= expected_x - 1
|
||||
assert max(green_columns) > width // 2 # should be on right half
|
||||
|
||||
def test_prepend_overlay_positions_to_left(self, tmp_path):
|
||||
"""Prepend overlays should be positioned to the left side with spacing applied."""
|
||||
Image = pytest.importorskip("PIL.Image")
|
||||
with patch('src.core.generation_engine.bpy') as mock_bpy:
|
||||
width = height = 64
|
||||
overlay_path = tmp_path / "prepend.png"
|
||||
overlay_source = Image.new("RGBA", (10, 10), (0, 0, 255, 255))
|
||||
overlay_source.save(overlay_path)
|
||||
|
||||
mock_blender_image = MagicMock()
|
||||
mock_blender_image.pixels = [0.0] * (width * height * 4)
|
||||
mock_bpy.data.images.new.return_value = mock_blender_image
|
||||
mock_bpy.data.images.__contains__.return_value = False
|
||||
|
||||
props = create_mock_props(
|
||||
text="",
|
||||
background_transparent=False,
|
||||
background_color=(1.0, 1.0, 1.0, 1.0)
|
||||
)
|
||||
|
||||
overlay = MagicMock()
|
||||
overlay.enabled = True
|
||||
overlay.image_path = str(overlay_path)
|
||||
overlay.positioning_mode = 'PREPEND'
|
||||
overlay.text_spacing = 10.0 # push further left
|
||||
overlay.image_spacing = 0.0
|
||||
overlay.scale = 1.0
|
||||
overlay.rotation = 0.0
|
||||
overlay.z_index = 0
|
||||
overlay.x_position = 0.5
|
||||
overlay.y_position = 0.5
|
||||
props.image_overlays = [overlay]
|
||||
|
||||
result, normal_map = generate_texture_image(props, width, height)
|
||||
|
||||
assert result is mock_blender_image
|
||||
assert normal_map is None
|
||||
|
||||
spacing_ratio = overlay.text_spacing / 100.0
|
||||
center_x = (width * 0.5) - (spacing_ratio * width) - (overlay_source.width / 2.0)
|
||||
expected_x = int(round(center_x - overlay_source.width / 2.0))
|
||||
expected_y = int(round((height * overlay.y_position) - (overlay_source.height / 2.0)))
|
||||
expected_y = max(0, min(height - overlay_source.height, expected_y))
|
||||
|
||||
sample_row = expected_y + overlay_source.height // 2
|
||||
blue_columns = []
|
||||
for x in range(width):
|
||||
idx = (sample_row * width + x) * 4
|
||||
pixel = mock_blender_image.pixels[idx:idx + 4]
|
||||
if pixel and pixel[2] >= 0.95 and pixel[0] <= 0.1 and pixel[1] <= 0.1:
|
||||
blue_columns.append(x)
|
||||
|
||||
assert blue_columns, "Prepend overlay pixels not found"
|
||||
assert min(blue_columns) <= expected_x + 1
|
||||
assert max(blue_columns) <= expected_x + overlay_source.width
|
||||
assert max(blue_columns) < width // 2 # should remain on left half
|
||||
|
||||
def test_text_fitting_enabled(self):
|
||||
"""Test text fitting integration."""
|
||||
with patch('PIL.Image') as mock_pil_image_module, \
|
||||
@@ -696,4 +889,4 @@ class TestGeneratePreview:
|
||||
result = generate_preview(props, preview_width=128, preview_height=128)
|
||||
|
||||
# Should handle error gracefully
|
||||
assert result is None
|
||||
assert result is None
|
||||
|
||||
Reference in New Issue
Block a user