Files
EricRorich-ComfyUI-face-shaper/face_shaper.py

1118 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Refined implementation of the ComfyUI Face Shaper custom node.
This module implements a custom node that draws a stylized facial mask based on
SVG‑derived coordinates extracted from Face_Mask_female.svg (viewBox 270.93331×270.93331).
Users can adjust the positions and sizes of distinct facial features including:
outer head outline, eyes (with independent rotation), irises, eyebrows, nose (single
merged object with integrated tip control), and lips (upper and lower with direction-specific
scaling). All coordinates are normalized to [0-1] range. Users can select
a gender preset (currently both genders use the same coordinates), change the canvas
size and camera distance, and control the line thickness. The output is black lines
on either a white or transparent background as a tensor compatible with ComfyUI workflows.
Key improvements in this version:
- Eyes updated with new geometry from SVG (path190/path188)
- Independent eye rotation controls (eye_left_rotation, eye_right_rotation)
- Chin geometry completely removed from node
- Nose tip now integrated within nose polyline (no separate geometry)
- nose_tip_pos_y controls only the 3 middle-most, lowest-Y points of nose
- Removed obsolete nose_tip size controls (nose_tip_size_x, nose_tip_size_y)
- Lips split into upper and lower with independent y-scaling
- Direction-specific lip scaling keeps mouth midline anchored
- Nose simplified to 3 parameters: pos_y, size_x, size_y + integrated tip control
- All position parameters default to 0.0 (zero offsets by default)
- Per-feature scaling controls prevent distortion across unrelated features
- Camera translation controls (camera_pos_x, camera_pos_y) for panning the view
- Settings export/import via LIST output and input for saving/loading configurations
- Iris positions based on SVG viewBox coordinates for accurate defaults
- Finer iris position controls (2x finer offsets)
- Reset to defaults via client-side web extension button
- Maintains proper node structure with CATEGORY="face", INPUT_TYPES, RETURN_TYPES=("IMAGE", "LIST")
- Optional transparent background support for alpha-aware workflows
"""
from typing import Dict, List, Tuple
import math
import re
import numpy as np
import torch
from PIL import Image, ImageDraw
def _parse_svg_path_to_polylines(path_d: str, segments_per_curve: int = 32) -> List[List[Tuple[float, float]]]:
"""
Parse an SVG path string into a list of polylines, preserving subpaths.
Supports M/m, L/l, H/h, V/v, Z/z, C/c, Q/q commands.
Curves (C, Q) are sampled to polylines with specified segments.
Args:
path_d: SVG path 'd' attribute string
segments_per_curve: Number of line segments to use per curve (≥24 recommended)
Returns:
List of polylines, where each polyline is a list of (x, y) tuples.
Each 'M' command starts a new subpath/polyline.
"""
# Tokenize the path string - split on command letters while keeping them
tokens = re.findall(r'[MmLlHhVvZzCcQqSsTtAa]|[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?', path_d)
polylines = []
current_polyline = []
current_pos = (0.0, 0.0)
subpath_start = (0.0, 0.0)
last_command = None
i = 0
while i < len(tokens):
token = tokens[i]
# Check if this is a command letter
if token in 'MmLlHhVvZzCcQqSsTtAa':
command = token
i += 1
# Process command
if command in 'Mm': # Move
# M/m starts a new subpath
if current_polyline:
polylines.append(current_polyline)
current_polyline = []
# Get coordinates
x = float(tokens[i])
y = float(tokens[i + 1])
i += 2
if command == 'm': # relative
x += current_pos[0]
y += current_pos[1]
current_pos = (x, y)
subpath_start = current_pos
current_polyline.append(current_pos)
elif command in 'Ll': # Line to
x = float(tokens[i])
y = float(tokens[i + 1])
i += 2
if command == 'l': # relative
x += current_pos[0]
y += current_pos[1]
current_pos = (x, y)
current_polyline.append(current_pos)
elif command in 'Hh': # Horizontal line
x = float(tokens[i])
i += 1
if command == 'h': # relative
x += current_pos[0]
current_pos = (x, current_pos[1])
current_polyline.append(current_pos)
elif command in 'Vv': # Vertical line
y = float(tokens[i])
i += 1
if command == 'v': # relative
y += current_pos[1]
current_pos = (current_pos[0], y)
current_polyline.append(current_pos)
elif command in 'Zz': # Close path
# Close the path by connecting to subpath start
if current_polyline and current_polyline[-1] != subpath_start:
current_polyline.append(subpath_start)
# Don't start a new polyline yet - Z might be followed by more commands
elif command in 'Cc': # Cubic Bezier
# Get control points and end point
x1 = float(tokens[i])
y1 = float(tokens[i + 1])
x2 = float(tokens[i + 2])
y2 = float(tokens[i + 3])
x = float(tokens[i + 4])
y = float(tokens[i + 5])
i += 6
if command == 'c': # relative
x1 += current_pos[0]
y1 += current_pos[1]
x2 += current_pos[0]
y2 += current_pos[1]
x += current_pos[0]
y += current_pos[1]
# Sample the cubic Bezier curve
p0 = current_pos
p1 = (x1, y1)
p2 = (x2, y2)
p3 = (x, y)
for j in range(1, segments_per_curve + 1):
t = j / segments_per_curve
# Cubic Bezier formula
t1 = 1 - t
bx = t1**3 * p0[0] + 3 * t1**2 * t * p1[0] + 3 * t1 * t**2 * p2[0] + t**3 * p3[0]
by = t1**3 * p0[1] + 3 * t1**2 * t * p1[1] + 3 * t1 * t**2 * p2[1] + t**3 * p3[1]
current_polyline.append((bx, by))
current_pos = (x, y)
elif command in 'Qq': # Quadratic Bezier
# Get control point and end point
x1 = float(tokens[i])
y1 = float(tokens[i + 1])
x = float(tokens[i + 2])
y = float(tokens[i + 3])
i += 4
if command == 'q': # relative
x1 += current_pos[0]
y1 += current_pos[1]
x += current_pos[0]
y += current_pos[1]
# Sample the quadratic Bezier curve
p0 = current_pos
p1 = (x1, y1)
p2 = (x, y)
for j in range(1, segments_per_curve + 1):
t = j / segments_per_curve
# Quadratic Bezier formula
t1 = 1 - t
bx = t1**2 * p0[0] + 2 * t1 * t * p1[0] + t**2 * p2[0]
by = t1**2 * p0[1] + 2 * t1 * t * p1[1] + t**2 * p2[1]
current_polyline.append((bx, by))
current_pos = (x, y)
else:
# Unsupported command (S, T, A) - skip for now
# Could implement if needed
pass
last_command = command
else:
# This shouldn't happen with proper tokenization
i += 1
# Add final polyline if any
if current_polyline:
polylines.append(current_polyline)
return polylines
def _normalize_svg_coordinates(polylines: List[List[Tuple[float, float]]],
viewbox_x: float, viewbox_y: float,
viewbox_w: float, viewbox_h: float) -> List[List[Tuple[float, float]]]:
"""
Normalize SVG coordinates from viewBox space to [0, 1] range.
Args:
polylines: List of polylines in viewBox coordinates
viewbox_x, viewbox_y: ViewBox origin (typically 0, 0)
viewbox_w, viewbox_h: ViewBox dimensions
Returns:
List of polylines with normalized coordinates in [0, 1] range
"""
normalized = []
for polyline in polylines:
norm_polyline = []
for x, y in polyline:
# Normalize to [0, 1] range
norm_x = (x - viewbox_x) / viewbox_w
norm_y = (y - viewbox_y) / viewbox_h
norm_polyline.append((norm_x, norm_y))
normalized.append(norm_polyline)
return normalized
# relative coordinates for female face (0–1 range)
# Extracted from Face_Mask_female.svg using viewBox (0, 0, 270.93331, 270.93331) normalization
# Note: Coordinate precision varies (6-7 decimal places) to preserve exact SVG transform results
FEMALE_FACE: Dict[str, List[Tuple[float, float]]] = {
# Outer head outline - extracted from path173 with g41 transform applied
"outer_head": [
(0.428240, 0.935952),
(0.320690, 0.854875),
(0.247886, 0.752289),
(0.208175, 0.621574),
(0.208175, 0.547116),
(0.181701, 0.464385),
(0.208175, 0.381654),
(0.208285, 0.218750),
(0.265626, 0.125000),
(0.373638, 0.072239),
(0.500000, 0.062500),
(0.626362, 0.072239),
(0.734375, 0.125000),
(0.791715, 0.218750),
(0.791825, 0.381654),
(0.818299, 0.464385),
(0.791825, 0.547116),
(0.791825, 0.621574),
(0.752114, 0.752289),
(0.679310, 0.854875),
(0.571760, 0.935952),
(0.500000, 0.953125),
(0.428240, 0.935952), # Close the polyline (z command)
],
# Lips - now split into upper and lower (single shapes each)
# CORRECTED: lips_upper is now the actual upper lip (smaller y values)
# and lips_lower is the actual lower lip (larger y values)
"lips_upper": [
(0.459189, 0.749262),
(0.444298, 0.754226),
(0.391922, 0.753943),
(0.475736, 0.726097),
(0.500000, 0.736025),
(0.524265, 0.726097),
(0.608079, 0.753943),
(0.555702, 0.754226),
(0.540811, 0.749262),
(0.500000, 0.764153),
(0.459189, 0.749262), # Close the polyline
],
"lips_lower": [
(0.458618, 0.750634),
(0.442072, 0.754981),
(0.392433, 0.754258),
(0.450345, 0.800273),
(0.487500, 0.800000),
(0.500000, 0.796875),
(0.512500, 0.800000),
(0.549655, 0.800273),
(0.607567, 0.754258),
(0.557928, 0.754981),
(0.541382, 0.750634),
(0.500001, 0.762932),
(0.458618, 0.750634), # Close the polyline
],
# Eyes - extracted from Face_Mask_female.svg path190 (right) and path188 (left)
# Normalized using viewBox (0, 0, 270.93331, 270.93331)
"eye_right": [
(0.709375, 0.437500),
(0.693750, 0.425000),
(0.671875, 0.412500),
(0.653125, 0.409375),
(0.631250, 0.412500),
(0.615625, 0.421875),
(0.600000, 0.434375),
(0.584375, 0.446875),
(0.578125, 0.459375),
(0.603125, 0.456250),
(0.621875, 0.459375),
(0.646875, 0.465625),
(0.662500, 0.465625),
(0.681250, 0.459375),
(0.693750, 0.453125),
(0.709375, 0.437500), # Close the polyline
],
"eye_left": [
(0.290625, 0.437500),
(0.306250, 0.425000),
(0.328125, 0.412500),
(0.346875, 0.409375),
(0.368750, 0.412500),
(0.384375, 0.421875),
(0.400000, 0.434375),
(0.415625, 0.446875),
(0.421875, 0.459375),
(0.396875, 0.456250),
(0.378125, 0.459375),
(0.353125, 0.465625),
(0.337500, 0.465625),
(0.318750, 0.459375),
(0.306250, 0.453125),
(0.290625, 0.437500), # Close the polyline
],
# Eyebrows
"eyebrow_right": [
(0.568969, 0.383450),
(0.721171, 0.333225),
(0.792342, 0.386617),
(0.721472, 0.361798),
(0.585515, 0.406908),
],
"eyebrow_left": [
(0.431031, 0.383450),
(0.278829, 0.333225),
(0.207658, 0.386617),
(0.278528, 0.361798),
(0.414485, 0.406908),
],
# Nose - single merged object (updated from SVG path46, 13 points)
# Tip points are indices 5, 6, 7 (middle-most, lowest Y points)
"nose": [
(0.455541, 0.542654), # [0]
(0.428341, 0.619195), # [1]
(0.430238, 0.648926), # [2]
(0.455541, 0.655884), # [3]
(0.459375, 0.634375), # [4]
(0.487500, 0.656250), # [5] tip point (left)
(0.500000, 0.659375), # [6] tip point (center, lowest)
(0.512500, 0.656250), # [7] tip point (right)
(0.540625, 0.634375), # [8]
(0.544459, 0.655884), # [9]
(0.569762, 0.648926), # [10]
(0.571659, 0.619195), # [11]
(0.544459, 0.542654), # [12]
],
}
# Iris data is kept separate because irises are drawn as circles.
# Extracted from Face_Mask_female.svg (1024x1024 file using viewBox 270.93331x270.93331)
# Normalized using SVG viewBox coordinates:
# - Right iris: cx=170.09473, cy=115.99001, r=7.36947 → normalized by viewBox dimensions
# - Left iris: cx=100.8386, cy=115.99001, r=7.36947 → normalized by viewBox dimensions
# Baked offsets: left iris -0.020 (from -0.040*0.5), right iris +0.020 (from +0.040*0.5)
# to achieve correct visual alignment with iris_pos_x defaults at 0.0
FEMALE_FACE_IRISES = {
"iris_right": {"center": (0.6478103272, 0.4281127706), "radius": 0.0272003099},
"iris_left": {"center": (0.3521897466, 0.4281127706), "radius": 0.0272003099},
}
# Number of parameters in settings_list (for import/export functionality)
# NEW format: 45 parameters total (after cheek removal)
# Breakdown:
# - 0-42: 43 feature control parameters (eyes, eyebrows, nose, lips, camera, etc.)
# - 43-44: 2 canvas dimensions (width, height) - exported but not imported
# OLD format had 50 parameters with cheek controls at indices 24-27 and 49
# Set to 60 to allow for future expansion
SETTINGS_LIST_LENGTH = 60
def _face_data_for_gender(gender: str):
"""Return the polyline coordinates for the requested gender preset."""
# TODO: replace with male‑specific coordinates once available; currently both
# genders use the female mask.
return FEMALE_FACE
def _iris_data_for_gender(gender: str):
"""Return the iris data for the requested gender preset."""
# TODO: replace with male‑specific coordinates once available; currently both
# genders use the female mask.
return FEMALE_FACE_IRISES
class ComfyUIFaceShaper:
"""Custom node that draws a parametric facial mask."""
CATEGORY = "face"
RETURN_TYPES = ("IMAGE", "LIST")
FUNCTION = "draw_face"
@classmethod
def INPUT_TYPES(cls):
"""Define adjustable parameters for the node."""
return {
"required": {
"canvas_width": ("INT", {"default": 1024, "min": 256, "max": 2048}),
"canvas_height": ("INT", {"default": 1024, "min": 256, "max": 2048}),
"transparent_background": ("BOOLEAN", {"default": False}),
"line_thickness": (
"FLOAT",
{"default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1},
),
"gender": (["female", "male"],),
"fov_mm": (
"FLOAT",
{"default": 80.0, "min": 16.0, "max": 200.0, "step": 1.0},
),
"camera_distance": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"camera_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"camera_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"head_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"head_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"jaw_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"fore_head_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"eye_left_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"eye_left_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"eye_left_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eye_left_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eye_left_rotation": (
"FLOAT",
{"default": 0.0, "min": -45.0, "max": 45.0, "step": 0.1},
),
"eye_right_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"eye_right_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"eye_right_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eye_right_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eye_right_rotation": (
"FLOAT",
{"default": 0.0, "min": -45.0, "max": 45.0, "step": 0.1},
),
"iris_left_size": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"iris_left_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.25, "max": 0.25, "step": 0.005},
),
"iris_left_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.25, "max": 0.25, "step": 0.005},
),
"iris_right_size": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.01},
),
"iris_right_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.25, "max": 0.25, "step": 0.005},
),
"iris_right_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.25, "max": 0.25, "step": 0.005},
),
"eyebrow_left_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"eyebrow_left_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"eyebrow_left_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eyebrow_left_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eyebrow_left_rotation": (
"FLOAT",
{"default": 0.0, "min": -45.0, "max": 45.0, "step": 0.1},
),
"eyebrow_right_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"eyebrow_right_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"eyebrow_right_pos_x": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eyebrow_right_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"eyebrow_right_rotation": (
"FLOAT",
{"default": 0.0, "min": -45.0, "max": 45.0, "step": 0.1},
),
"nose_pos_y": (
"FLOAT",
{"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01},
),
"nose_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01},
),
"nose_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01},
),
"nose_tip_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.2, "max": 0.2, "step": 0.005},
),
"lips_pos_y": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"lip_size_x": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01},
),
"lip_upper_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
"lip_lower_size_y": (
"FLOAT",
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
),
},
"optional": {
"settings_list": ("LIST", {"default": None}),
},
}
def draw_face(
self,
canvas_width: int,
canvas_height: int,
transparent_background: bool,
line_thickness: float,
gender: str,
fov_mm: float,
camera_distance: float,
camera_pos_x: float,
camera_pos_y: float,
head_size_x: float,
head_size_y: float,
jaw_size_x: float,
fore_head_size_x: float,
eye_left_size_x: float,
eye_left_size_y: float,
eye_left_pos_x: float,
eye_left_pos_y: float,
eye_left_rotation: float,
eye_right_size_x: float,
eye_right_size_y: float,
eye_right_pos_x: float,
eye_right_pos_y: float,
eye_right_rotation: float,
iris_left_size: float,
iris_left_pos_x: float,
iris_left_pos_y: float,
iris_right_size: float,
iris_right_pos_x: float,
iris_right_pos_y: float,
eyebrow_left_size_x: float,
eyebrow_left_size_y: float,
eyebrow_left_pos_x: float,
eyebrow_left_pos_y: float,
eyebrow_left_rotation: float,
eyebrow_right_size_x: float,
eyebrow_right_size_y: float,
eyebrow_right_pos_x: float,
eyebrow_right_pos_y: float,
eyebrow_right_rotation: float,
nose_pos_y: float,
nose_size_x: float,
nose_size_y: float,
nose_tip_pos_y: float,
lips_pos_y: float,
lip_size_x: float,
lip_upper_size_y: float,
lip_lower_size_y: float,
settings_list=None,
):
"""Render the facial mask image and return it as a tensor."""
# Handle settings_list import: override all adjustable parameters if provided
if settings_list is not None and len(settings_list) >= SETTINGS_LIST_LENGTH:
# Backward compatibility: check if old parameter names are present
# Old settings_list had outer_head_size_x/y at indices 16-17
# If settings_list[16] looks reasonable (0.5-2.0 range), assume it's the old format
# New format uses same indices but with renamed parameters
# Import all parameters from settings_list (indices match export order)
eye_left_size_x = settings_list[0]
eye_left_size_y = settings_list[1]
eye_left_pos_x = settings_list[2]
eye_left_pos_y = settings_list[3]
eye_right_size_x = settings_list[4]
eye_right_size_y = settings_list[5]
eye_right_pos_x = settings_list[6]
eye_right_pos_y = settings_list[7]
eye_left_rotation = settings_list[8]
eye_right_rotation = settings_list[9]
iris_left_size = settings_list[10]
iris_left_pos_x = settings_list[11]
iris_left_pos_y = settings_list[12]
iris_right_size = settings_list[13]
iris_right_pos_x = settings_list[14]
iris_right_pos_y = settings_list[15]
# Indices 16-17: old outer_head_size_x/y → new head_size_x/y (backward compatible)
head_size_x = settings_list[16]
head_size_y = settings_list[17]
jaw_size_x = settings_list[18]
# Index 19: old forehead_size_x → new fore_head_size_x (backward compatible)
fore_head_size_x = settings_list[19]
lips_pos_y = settings_list[20]
# Index 21: old lips_size_x → new lip_size_x (backward compatible)
lip_size_x = settings_list[21]
lip_upper_size_y = settings_list[22]
lip_lower_size_y = settings_list[23]
# Indices 24-27: old cheek parameters (now occupied by eyebrow params in new format)
# BACKWARD COMPATIBILITY: When importing OLD format settings_list:
# - OLD indices 24-27 contained cheek params (ignored)
# - OLD indices 28+ contained eyebrow/nose/camera params (load from these)
# - NEW indices 24+ will contain eyebrow/nose/camera params (shifted down after export)
# This ensures old workflows correctly restore eyebrow/nose/camera settings
eyebrow_left_size_x = settings_list[28]
eyebrow_left_size_y = settings_list[29]
eyebrow_left_rotation = settings_list[30]
eyebrow_left_pos_x = settings_list[31]
eyebrow_left_pos_y = settings_list[32]
eyebrow_right_size_x = settings_list[33]
eyebrow_right_size_y = settings_list[34]
eyebrow_right_rotation = settings_list[35]
eyebrow_right_pos_x = settings_list[36]
eyebrow_right_pos_y = settings_list[37]
nose_pos_y = settings_list[38]
nose_size_x = settings_list[39]
nose_size_y = settings_list[40]
nose_tip_pos_y = settings_list[41]
camera_distance = settings_list[42]
camera_pos_x = settings_list[43]
camera_pos_y = settings_list[44]
fov_mm = settings_list[45]
line_thickness = settings_list[46]
# Index 49: old cheeks_enabled (ignored for backward compatibility)
# Note: canvas_width/canvas_height (indices 47-48 in old format) are exported but not imported
# as they are always provided as direct parameters to the method
# New settings_export format: 45 parameters (indices 0-44), no cheek params
face_points = _face_data_for_gender(gender)
iris_data = _iris_data_for_gender(gender)
# Create a blank canvas - RGBA for transparent background, RGB for white background.
if transparent_background:
img = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 0))
else:
img = Image.new("RGB", (canvas_width, canvas_height), "white")
draw = ImageDraw.Draw(img)
# Pillow expects integer stroke widths; round and enforce a minimum of 1 so lines stay visible.
stroke_width = max(1, int(round(line_thickness)))
# Calculate radial distortion coefficient based on fov_mm
# Neutral at 80mm => k1=0
# For fov_mm < 80 (wide) use negative k1 (barrel distortion)
# For fov_mm > 80 use positive k1 (pincushion distortion)
t = fov_mm - 80.0
if t < 0:
tn = t / (80.0 - 16.0) # [-1, 0]
else:
tn = t / (200.0 - 80.0) # [0, 1]
k1 = tn * 0.18
# Use min(canvas_width, canvas_height) as base to ensure uniform scene scale.
# This prevents face stretching when canvas is non-square - the face stays
# proportional and centered, with extra canvas space on the larger dimension.
base = min(canvas_width, canvas_height)
scene_scale = base * camera_distance
center_x = canvas_width / 2.0
center_y = canvas_height / 2.0
def apply_distortion(x: float, y: float) -> Tuple[float, float]:
"""Apply radial distortion to pixel coordinates."""
if k1 == 0:
return (x, y)
cx = canvas_width / 2.0
cy = canvas_height / 2.0
dx = (x - cx) / cx
dy = (y - cy) / cy
r2 = dx * dx + dy * dy
factor = 1.0 + k1 * r2
x_distorted = cx + dx * factor * cx
y_distorted = cy + dy * factor * cy
return (x_distorted, y_distorted)
def to_pixel(point: Tuple[float, float]) -> Tuple[float, float]:
"""Convert relative coordinates in range [0,1] to pixel positions with distortion."""
rx, ry = point
dx = rx - 0.5 + camera_pos_x
dy = ry - 0.5 + camera_pos_y
x = center_x + dx * scene_scale
y = center_y + dy * scene_scale
return apply_distortion(x, y)
# Helper to scale and translate polygons.
def transform_polygon(
points: List[Tuple[float, float]],
scale_x: float,
scale_y: float,
offset_x: float,
offset_y: float,
) -> List[Tuple[float, float]]:
if not points:
return []
cx = sum(px for px, _ in points) / len(points)
cy = sum(py for _, py in points) / len(points)
transformed = []
for rx, ry in points:
dx = (rx - cx) * scale_x
dy = (ry - cy) * scale_y
transformed.append((cx + dx + offset_x, cy + dy + offset_y))
return transformed
# Helper to translate polygons.
def translate_polygon(
points: List[Tuple[float, float]],
offset_x: float,
offset_y: float,
) -> List[Tuple[float, float]]:
return [(rx + offset_x, ry + offset_y) for rx, ry in points]
# Helper to rotate points around a center by angle in degrees.
def rotate_polygon(
points: List[Tuple[float, float]],
angle_degrees: float,
cx: float,
cy: float,
) -> List[Tuple[float, float]]:
"""Rotate points around (cx, cy) by angle_degrees using 2D rotation."""
if angle_degrees == 0.0:
return points
angle_rad = math.radians(angle_degrees)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
rotated = []
for rx, ry in points:
# Translate to origin
dx = rx - cx
dy = ry - cy
# Rotate
new_x = dx * cos_a - dy * sin_a
new_y = dx * sin_a + dy * cos_a
# Translate back
rotated.append((cx + new_x, cy + new_y))
return rotated
# Helper to transform eyebrow: scale → rotate → translate
def transform_eyebrow(
points: List[Tuple[float, float]],
size_x: float,
size_y: float,
rotation_degrees: float,
pos_x: float,
pos_y: float,
) -> List[Tuple[float, float]]:
"""Apply scale, rotation, and translation to eyebrow points."""
# Calculate centroid
cx = sum(px for px, _ in points) / len(points)
cy = sum(py for _, py in points) / len(points)
# Step 1: Scale around centroid
scaled = []
for rx, ry in points:
dx = (rx - cx) * size_x
dy = (ry - cy) * size_y
scaled.append((cx + dx, cy + dy))
# Step 2: Rotate around centroid
rotated = rotate_polygon(scaled, rotation_degrees, cx, cy)
# Step 3: Apply positional offset
return translate_polygon(rotated, pos_x, pos_y)
# Helper to transform eye: scale → rotate → translate
def transform_eye(
points: List[Tuple[float, float]],
size_x: float,
size_y: float,
rotation_degrees: float,
pos_x: float,
pos_y: float,
) -> List[Tuple[float, float]]:
"""Apply scale, rotation, and translation to eye points."""
# Calculate centroid across all points
cx = sum(px for px, _ in points) / len(points)
cy = sum(py for _, py in points) / len(points)
# Step 1: Scale around centroid
scaled = []
for rx, ry in points:
dx = (rx - cx) * size_x
dy = (ry - cy) * size_y
scaled.append((cx + dx, cy + dy))
# Step 2: Rotate around centroid
rotated = rotate_polygon(scaled, rotation_degrees, cx, cy)
# Step 3: Apply positional offset
return translate_polygon(rotated, pos_x, pos_y)
# Draw outer head outline with per-region horizontal scaling
if "outer_head" in face_points:
# Calculate centroid for scaling
cx = sum(px for px, _ in face_points["outer_head"]) / len(face_points["outer_head"])
cy = sum(py for _, py in face_points["outer_head"]) / len(face_points["outer_head"])
outer_head = []
for rx, ry in face_points["outer_head"]:
# Determine which horizontal scale to use based on y position
if ry > 0.7:
# Jaw region (bottom)
scale_x = jaw_size_x
elif ry < 0.3:
# Forehead region (top)
scale_x = fore_head_size_x
else:
# Mid region
scale_x = head_size_x
# Apply scaling
dx = (rx - cx) * scale_x
dy = (ry - cy) * head_size_y
outer_head.append((cx + dx, cy + dy))
pixel_points = [to_pixel(pt) for pt in outer_head]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
# Draw eyebrows with scale → rotation → translation transform order
if "eyebrow_left" in face_points:
eyebrow_left = transform_eyebrow(
face_points["eyebrow_left"],
eyebrow_left_size_x,
eyebrow_left_size_y,
eyebrow_left_rotation,
eyebrow_left_pos_x,
eyebrow_left_pos_y,
)
pixel_points = [to_pixel(pt) for pt in eyebrow_left]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
if "eyebrow_right" in face_points:
eyebrow_right = transform_eyebrow(
face_points["eyebrow_right"],
eyebrow_right_size_x,
eyebrow_right_size_y,
eyebrow_right_rotation,
eyebrow_right_pos_x,
eyebrow_right_pos_y,
)
pixel_points = [to_pixel(pt) for pt in eyebrow_right]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
# Draw nose (single merged object) with scaling, y-positioning, and integrated tip control
if "nose" in face_points:
# First apply scaling and main y-positioning
nose = transform_polygon(
face_points["nose"],
nose_size_x,
nose_size_y,
0.0,
nose_pos_y,
)
# Algorithmically select the 3 tip points (lowest on face, most central)
# Sort by: 1) ry descending (lowest on face first), then 2) abs(rx-0.5) ascending (most central)
face_center_x = 0.5
indexed_points = [(idx, x, y) for idx, (x, y) in enumerate(nose)]
sorted_points = sorted(indexed_points, key=lambda p: (-p[2], abs(p[1] - face_center_x)))
nose_tip_indices = {p[0] for p in sorted_points[:3]}
# Apply nose_tip_pos_y to the selected 3 tip points only
nose_with_tip = []
for idx, (x, y) in enumerate(nose):
if idx in nose_tip_indices:
# Apply tip position offset (positive moves down in y-down coordinate system)
nose_with_tip.append((x, y + nose_tip_pos_y))
else:
nose_with_tip.append((x, y))
# Draw the nose
pixel_points = [to_pixel(pt) for pt in nose_with_tip]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
# Draw lips (upper and lower) with direction-specific scaling
# Mouth midline is approximately at y = 0.757394
mouth_midline_y = 0.757394
# Upper lip - scale upward (decreasing y, toward top of image)
if "lips_upper" in face_points:
lips_upper_scaled = []
for rx, ry in face_points["lips_upper"]:
# Apply horizontal scaling around center
cx = sum(px for px, _ in face_points["lips_upper"]) / len(face_points["lips_upper"])
dx = (rx - cx) * lip_size_x
new_x = cx + dx
# Apply vertical scaling only in upward direction from midline
# Upper lip is above midline, so we scale away from midline (decreasing y)
dy_from_midline = ry - mouth_midline_y # negative for upper lip
scaled_dy = dy_from_midline * lip_upper_size_y
new_y = mouth_midline_y + scaled_dy + lips_pos_y
lips_upper_scaled.append((new_x, new_y))
pixel_points = [to_pixel(pt) for pt in lips_upper_scaled]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
# Lower lip - scale downward (increasing y, toward bottom of image)
if "lips_lower" in face_points:
lips_lower_scaled = []
for rx, ry in face_points["lips_lower"]:
# Apply horizontal scaling around center
cx = sum(px for px, _ in face_points["lips_lower"]) / len(face_points["lips_lower"])
dx = (rx - cx) * lip_size_x
new_x = cx + dx
# Apply vertical scaling only in downward direction from midline
# Lower lip is below midline, so we scale away from midline (increasing y)
dy_from_midline = ry - mouth_midline_y # positive for lower lip
scaled_dy = dy_from_midline * lip_lower_size_y
new_y = mouth_midline_y + scaled_dy + lips_pos_y
lips_lower_scaled.append((new_x, new_y))
pixel_points = [to_pixel(pt) for pt in lips_lower_scaled]
draw.line(pixel_points, fill=(0, 0, 0), width=stroke_width)
# Transform and draw both eyes with scale → rotate → translate
eye_right = transform_eye(
face_points["eye_right"],
eye_right_size_x,
eye_right_size_y,
eye_right_rotation,
eye_right_pos_x,
eye_right_pos_y,
)
eye_left = transform_eye(
face_points["eye_left"],
eye_left_size_x,
eye_left_size_y,
eye_left_rotation,
eye_left_pos_x,
eye_left_pos_y,
)
draw.line([to_pixel(pt) for pt in eye_right], fill=(0, 0, 0), width=stroke_width)
draw.line([to_pixel(pt) for pt in eye_left], fill=(0, 0, 0), width=stroke_width)
# Draw irises as circles with separate controls for each.
def draw_iris_with_params(center_key: str, iris_size: float, iris_pos_x: float, iris_pos_y: float):
base_center = iris_data[center_key]["center"]
base_radius = iris_data[center_key]["radius"]
# Apply 0.5 multiplier to make iris position controls 2x finer
cx_rel = base_center[0] + (iris_pos_x * 0.5)
cy_rel = base_center[1] + (iris_pos_y * 0.5)
cx, cy = to_pixel((cx_rel, cy_rel))
# Use the uniform base (min canvas dimension) for consistent scaling with to_pixel.
radius_px = base_radius * iris_size * base * camera_distance
bbox = [
cx - radius_px,
cy - radius_px,
cx + radius_px,
cy + radius_px,
]
draw.ellipse(bbox, outline=(0, 0, 0), width=stroke_width)
draw_iris_with_params("iris_right", iris_right_size, iris_right_pos_x, iris_right_pos_y)
draw_iris_with_params("iris_left", iris_left_size, iris_left_pos_x, iris_left_pos_y)
# Convert the PIL image to a tensor of shape [B, H, W, C] (B=1).
# The image is already in the correct format (RGB or RGBA) based on how it was created.
# For white background: RGB mode → 3 channels
# For transparent background: RGBA mode → 4 channels
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr).unsqueeze(0)
# Create settings list with all adjustable parameters in consistent order
settings_export = [
eye_left_size_x,
eye_left_size_y,
eye_left_pos_x,
eye_left_pos_y,
eye_right_size_x,
eye_right_size_y,
eye_right_pos_x,
eye_right_pos_y,
eye_left_rotation,
eye_right_rotation,
iris_left_size,
iris_left_pos_x,
iris_left_pos_y,
iris_right_size,
iris_right_pos_x,
iris_right_pos_y,
head_size_x,
head_size_y,
jaw_size_x,
fore_head_size_x,
lips_pos_y,
lip_size_x,
lip_upper_size_y,
lip_lower_size_y,
eyebrow_left_size_x,
eyebrow_left_size_y,
eyebrow_left_rotation,
eyebrow_left_pos_x,
eyebrow_left_pos_y,
eyebrow_right_size_x,
eyebrow_right_size_y,
eyebrow_right_rotation,
eyebrow_right_pos_x,
eyebrow_right_pos_y,
nose_pos_y,
nose_size_x,
nose_size_y,
nose_tip_pos_y,
camera_distance,
camera_pos_x,
camera_pos_y,
fov_mm,
line_thickness,
canvas_width,
canvas_height,
]
return (tensor, settings_export)
# Register the node with hyphen‑free identifiers. These mappings are used by
# ComfyUI to locate and instantiate the node. Avoiding hyphens ensures the
# identifiers are valid Python identifiers, which improves compatibility with
# ComfyUI’s loading mechanism.
NODE_CLASS_MAPPINGS = {
"Rorich": ComfyUIFaceShaper,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Rorich": "Face Shaper",
}