Co-authored-by: EricRorich <86147326+EricRorich@users.noreply.github.com>
1117 lines
44 KiB
Python
1117 lines
44 KiB
Python
"""
|
||
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": -0.5, "max": 0.5, "step": 0.01},
|
||
),
|
||
"nose_size_x": (
|
||
"FLOAT",
|
||
{"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01},
|
||
),
|
||
"nose_size_y": (
|
||
"FLOAT",
|
||
{"default": 1.0, "min": 0.5, "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.5, "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",
|
||
} |