Files
moonwhaler-comfyui-moonpack/_label_ops.py
T
moonwhaler faa30912eb fix: use a real bold system font instead of Pillow's default face
Pillow's load_default() font is a thin geometric face that renders
mushy at small pixel sizes even with a stroke faking boldness. Look
up a proper hinted bold font (Liberation Sans / DejaVu Sans Bold,
common Linux/Windows/macOS paths) and use it directly; fall back to
load_default() with the old stroke trick only if none is found.
2026-08-13 21:51:31 +02:00

175 lines
6.4 KiB
Python

"""Compositing for the Load Image (Labeled) node.
Shared by the node's own execution (`labeled_load_image_node.py`) and the
`/moonpack/label_preview` HTTP route, so the editor preview is pixel-identical
to what actually gets queued - one implementation, not two.
"""
import os
from PIL import Image as PILImage
from PIL import ImageDraw, ImageFont
BAR_HEIGHT_RATIO = 0.08
MIN_BAR_PX = 48
ARROW_ZONE_RATIO = 0.4
MIN_FONT_PX = 16
TEXT_WIDTH_RATIO = 0.92
# Pillow's own load_default() font is a thin geometric face that reads mushy
# at small pixel sizes even with a stroke faking boldness. A real hinted bold
# font looks crisp at the same size, so prefer whichever of these the host
# actually has installed - common Linux/Windows/macOS bold sans paths, most
# widely available first. Falls back to load_default() if none exist.
_BOLD_FONT_CANDIDATES = (
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/liberation-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"C:\\Windows\\Fonts\\seguisb.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf",
)
_bold_font_path = None
_bold_font_path_checked = False
def _find_bold_font_path():
global _bold_font_path, _bold_font_path_checked
if not _bold_font_path_checked:
_bold_font_path_checked = True
for path in _BOLD_FONT_CANDIDATES:
if os.path.isfile(path):
_bold_font_path = path
break
return _bold_font_path
def _load_font(size):
"""Returns (font, is_real_bold_face). Caller uses the flag to decide
whether a stroke is still needed to fake boldness."""
path = _find_bold_font_path()
if path:
return ImageFont.truetype(path, size), True
return ImageFont.load_default(size=size), False
def _hex_to_rgb(hex_str, fallback=(0, 0, 0)):
if not isinstance(hex_str, str):
return fallback
s = hex_str.strip().lstrip("#")
if len(s) != 6:
return fallback
try:
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
except ValueError:
return fallback
def _fit_text(draw, text, max_width, max_font_px):
"""Shrinks font size to fit max_width, then truncates with an ellipsis if
even the floor size overflows. Returns (font, is_bold_face, drawn_text)."""
size = max(MIN_FONT_PX, max_font_px)
font, is_bold = _load_font(size)
while size > MIN_FONT_PX and draw.textlength(text, font=font) > max_width:
size -= 1
font, is_bold = _load_font(size)
if draw.textlength(text, font=font) <= max_width:
return font, is_bold, text
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
candidate = text[:mid] + "…"
if draw.textlength(candidate, font=font) <= max_width:
lo = mid
else:
hi = mid - 1
return font, is_bold, (text[:lo] + "…" if lo > 0 else "…")
def _draw_arrow(draw, center_x, zone_top, zone_h, point_down, color_rgb):
zone_h = max(1, zone_h)
arrow_h = max(4, round(zone_h * 0.55))
arrow_w = round(arrow_h * 1.8)
center_y = zone_top + zone_h / 2
if point_down:
points = [
(center_x - arrow_w / 2, center_y - arrow_h / 2),
(center_x + arrow_w / 2, center_y - arrow_h / 2),
(center_x, center_y + arrow_h / 2),
]
else:
points = [
(center_x - arrow_w / 2, center_y + arrow_h / 2),
(center_x + arrow_w / 2, center_y + arrow_h / 2),
(center_x, center_y - arrow_h / 2),
]
draw.polygon(points, fill=color_rgb)
def compose_labeled_image(img, text, position, show_label, show_arrow,
background_hex, text_color_hex, border_width):
"""img must already be a converted RGB PIL.Image. Returns a new RGB PIL.Image
with the label bar (text and/or arrow) added as extra top/bottom space, and
an optional inset border - never changes width, never adds height beyond the
bar itself."""
width, height = img.size
bg_rgb = _hex_to_rgb(background_hex, (0, 0, 0))
text_rgb = _hex_to_rgb(text_color_hex, (255, 255, 255))
bar_height = max(MIN_BAR_PX, round(height * BAR_HEIGHT_RATIO)) if (show_label or show_arrow) else 0
canvas = PILImage.new("RGB", (width, height + bar_height), bg_rgb)
canvas.paste(img, (0, bar_height if position == "top" else 0))
if bar_height:
bar_top = 0 if position == "top" else height
draw = ImageDraw.Draw(canvas)
point_down = position == "top" # bar sits above the photo either way it needs to point toward it
if show_label and show_arrow:
arrow_zone_h = max(8, round(bar_height * ARROW_ZONE_RATIO))
text_zone_h = bar_height - arrow_zone_h
if position == "top":
text_zone_top, arrow_zone_top = bar_top, bar_top + text_zone_h
else:
arrow_zone_top, text_zone_top = bar_top, bar_top + arrow_zone_h
elif show_arrow:
arrow_zone_h, text_zone_h = bar_height, 0
arrow_zone_top = bar_top
else:
text_zone_h, arrow_zone_h = bar_height, 0
text_zone_top = bar_top
if show_arrow:
_draw_arrow(draw, width / 2, arrow_zone_top, arrow_zone_h, point_down, text_rgb)
if show_label and text:
max_font_px = max(MIN_FONT_PX, round(text_zone_h * 0.62))
font, is_bold, shown = _fit_text(draw, text, width * TEXT_WIDTH_RATIO, max_font_px)
# Already a bold face: no stroke, it would just clog up small glyphs.
# Fallback face has no bold weight: fake it with a thin stroke instead.
stroke_width = 0 if is_bold else max(1, round(font.size / 18))
draw.text(
(width / 2, text_zone_top + text_zone_h / 2),
shown, font=font, fill=text_rgb, stroke_width=stroke_width,
stroke_fill=text_rgb, anchor="mm",
)
if border_width > 0:
bw = min(border_width, min(canvas.size) // 4)
draw = ImageDraw.Draw(canvas)
for i in range(bw):
draw.rectangle(
[i, i, canvas.width - 1 - i, canvas.height - 1 - i],
outline=bg_rgb,
)
return canvas