Add color, outline, and background controls

This commit is contained in:
drozbay 2026-07-05 17:45:01 -06:00
parent d8711f0460
commit 940a9063ec

View File

@ -11,65 +11,76 @@ class TextOverlay(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="TextOverlay", node_id="TextOverlay",
display_name="Text Overlay", display_name="Draw Text Overlay",
category="image/text", category="text",
description="Overlay text on an image or batch of images.", description="Draw overlay text on an image or batch of images.",
search_aliases=["text", "label", "caption", "subtitle", "watermark", "title", "addlabel", "overlay"], search_aliases=["text", "label", "caption", "subtitle", "watermark", "title", "addlabel", "overlay"],
inputs=[ inputs=[
IO.Image.Input("image"), IO.Image.Input("image"),
IO.String.Input("text", multiline=True, default=""), IO.String.Input("text", multiline=True, default=""),
IO.Float.Input("font_size", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height."),
IO.Color.Input("color", default="#ffffff", tooltip="Color of the text."),
IO.Combo.Input("position", options=["top", "bottom"], default="top"), IO.Combo.Input("position", options=["top", "bottom"], default="top"),
IO.Combo.Input("align", options=["left", "center", "right"], default="left"), IO.Combo.Input("align", options=["left", "center", "right"], default="left"),
IO.Float.Input("font_size_percent", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height.", advanced=True), IO.Boolean.Input("outline", default=True, tooltip="Draw a black outline around the text."),
#IO.Combo.Input("text_color", options=["white", "black", "red", "green", "blue", "yellow", "cyan", "magenta", "gray"], default="white", tooltip="Color of the text.", advanced=True), IO.DynamicCombo.Input(
#IO.Combo.Input("outline_color", options=["auto", "none", "black", "white", "red", "green", "blue", "yellow"], default="auto", tooltip="Color of the text outline.", advanced=True), "background",
#IO.Float.Input("background_opacity", default=0.0, min=0.0, max=1.0, step=0.05, tooltip="Opacity of the background behind the text (0 = transparent, 1 = solid).", advanced=True), tooltip="Draw a banner behind the text to improve contrast.",
options=[
IO.DynamicCombo.Option("disabled", []),
IO.DynamicCombo.Option("enabled", [
IO.Color.Input("background_color", default="#00000080", tooltip="Color of the banner drawn behind the text. The color's opacity controls the banner transparency."),
]),
],
),
], ],
outputs=[IO.Image.Output()], outputs=[IO.Image.Output()],
) )
@classmethod @classmethod
def execute(cls, image, text, position="top", align="left", font_size_percent=5.0, text_color="white", outline_color="auto", def execute(cls, image, text, font_size, color, position, align, outline, background) -> IO.NodeOutput:
line_spacing=1.2, background_opacity=0.0) -> IO.NodeOutput:
if text.strip() == "": if text.strip() == "":
return IO.NodeOutput(image) return IO.NodeOutput(image)
text = text.replace("\\n", "\n").replace("\\t", "\t") text = text.replace("\\n", "\n").replace("\\t", "\t")
try: text_rgba = cls.parse_color_to_rgba(color)
text_rgb = ImageColor.getrgb(text_color)[:3] outline_rgba = (0, 0, 0, 255) if outline else (0, 0, 0, 0)
except ValueError:
text_rgb = (255, 255, 255)
# "auto" outline color: set the outline color based on text color to maintain contrast # The background banner opacity is set by the color's alpha channel, 50% by default
luminance = 0.299 * text_rgb[0] + 0.587 * text_rgb[1] + 0.114 * text_rgb[2] if background["background"] == "enabled":
contrast_rgb = (0, 0, 0) if luminance > 40 else (255, 255, 255) background_rgba = cls.parse_color_to_rgba(background["background_color"])
outline_choice = outline_color.lower()
if outline_choice == "none":
outline_rgb = None
elif outline_choice == "auto":
outline_rgb = contrast_rgb
else: else:
outline_rgb = ImageColor.getrgb(outline_color)[:3] background_rgba = (0, 0, 0, 0)
background_rgb = contrast_rgb if outline_rgb is None else outline_rgb
# Render the overlay once and composite it across all frames in the batch # Render the overlay once and composite it across all frames in the batch
height = int(image.shape[1]) height = image.shape[1]
width = int(image.shape[2]) width = image.shape[2]
overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size_percent, overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size,
text_rgb, outline_rgb, background_rgb, line_spacing, background_opacity) text_rgba, outline_rgba, background_rgba)
overlay_rgb = overlay_rgb.to(device=image.device, dtype=image.dtype) overlay_rgb = overlay_rgb.to(device=image.device, dtype=image.dtype)
overlay_alpha = overlay_alpha.to(device=image.device, dtype=image.dtype) overlay_alpha = overlay_alpha.to(device=image.device, dtype=image.dtype)
result = image * (1.0 - overlay_alpha) + overlay_rgb * overlay_alpha result = image * (1.0 - overlay_alpha) + overlay_rgb * overlay_alpha
return IO.NodeOutput(result) return IO.NodeOutput(result)
@staticmethod
def parse_color_to_rgba(color_string):
parsed = ImageColor.getrgb(color_string)
if len(parsed) == 3:
return (*parsed, 255)
return parsed
@classmethod @classmethod
def render_overlay_text(cls, width, height, text, position="top", align="left", font_size_percent=5.0, def render_overlay_text(cls, width, height, text, position, align, font_size, text_rgba, outline_rgba, background_rgba):
text_rgb=(255, 255, 255), outline_rgb=None, background_rgb=None, line_spacing=1.2, background_opacity=0.0, line_spacing = 1.2
margin_percent=1.0, min_font_percent=2.0, min_font_pixels=10, outline_thickness_factor=0.04): margin_percent = 1.0
min_font_percent = 2.0
min_font_pixels = 10
outline_thickness_factor = 0.04
# Draw onto a transparent layer so the result can be alpha-composited over any frame. # Draw onto a transparent layer so the result can be alpha-composited over any frame.
layer = PILImage.new("RGBA", (width, height), (0, 0, 0, 0)) layer = PILImage.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer) draw = ImageDraw.Draw(layer)
@ -79,12 +90,12 @@ class TextOverlay(IO.ComfyNode):
max_height = max(1, height - 2 * margin) max_height = max(1, height - 2 * margin)
# Font scales with resolution, then shrinks to fit the height. # Font scales with resolution, then shrinks to fit the height.
size = max(1, int(round(font_size_percent / 100.0 * height))) size = max(1, int(round(font_size / 100.0 * height)))
floor = min(size, max(min_font_pixels, int(round(min_font_percent / 100.0 * height)))) floor = min(size, max(min_font_pixels, int(round(min_font_percent / 100.0 * height))))
while True: while True:
font = ImageFont.load_default(size=size) font = ImageFont.load_default(size=size)
stroke = max(1, int(round(size * outline_thickness_factor))) if outline_rgb is not None else 0 stroke = max(1, int(round(size * outline_thickness_factor))) if outline_rgba[3] > 0 else 0
block = "\n".join(cls.wrap_text(text, font, max_width)) block = "\n".join(cls.wrap_text(text, font, max_width))
# convert line spacing to pixel spacing # convert line spacing to pixel spacing
single = draw.textbbox((0, 0), "Ay", font=font, stroke_width=stroke) single = draw.textbbox((0, 0), "Ay", font=font, stroke_width=stroke)
@ -99,20 +110,21 @@ class TextOverlay(IO.ComfyNode):
size = max(floor, int(size * 0.9)) size = max(floor, int(size * 0.9))
if background_opacity > 0: if background_rgba[3] > 0:
band = block_height + 2 * margin band = block_height + 2 * margin
rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band] rect = [0, height - band, width, height] if position == "bottom" else [0, 0, width, band]
draw.rectangle(rect, fill=(*background_rgb, int(round(background_opacity * 255)))) draw.rectangle(rect, fill=background_rgba)
anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align] anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align]
# Offset y to better center text vertically within its defined band
if position == "bottom": if position == "bottom":
anchor_v, y = "d", height - margin y = height - margin - box[3]
else: else:
anchor_v, y = "a", margin y = margin - box[1]
draw.multiline_text((x, y), block, font=font, fill=text_rgb, anchor=anchor_h + anchor_v, draw.multiline_text((x, y), block, font=font, fill=text_rgba, anchor=anchor_h + "a",
align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_rgb) align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_rgba)
overlay = np.array(layer).astype(np.float32) / 255.0 overlay = np.array(layer).astype(np.float32) / 255.0
overlay_rgb = torch.from_numpy(overlay[:, :, :3]) overlay_rgb = torch.from_numpy(overlay[:, :, :3])