Files
Superside ComfyUI NodesandClaude Opus 4.8 aab31ddbd7 Portrait Sections: partial_contract_percent to keep feather inside the region
Feathering a partial section alone spreads the soft edge outward past the
region's true border, leaving a faint ring on the surrounding skin. Add
partial_contract_percent (default 0 = off): erode partial-opacity sections
inward before feathering so the ramp sits inside the region. Set it near/above
partial_feather_percent to pull the feathered edge back to the true border.
Only affects partial (<1.0 opacity) sections; opacity 1.0 stays byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 12:15:00 -04:00

412 lines
20 KiB
Python

import base64
import io
import json
import logging
import numpy as np
import requests
import torch
from PIL import Image
from .base_node import SupersideFalNode, ImageProcessingMixin, APIClientMixin, API_KEY_INPUT_SPEC
logger = logging.getLogger(__name__)
# Toggle name -> SAM 3 text prompt. Consolidates the original local
# face-parsing model's 19-class taxonomy (which had separate l_eye/r_eye,
# l_ear/r_ear, u_lip/l_lip, l_brow/r_brow classes) into one open-vocabulary
# SAM 3 prompt per toggle - SAM 3's "merge_all" selection mode already
# picks up both left+right instances of e.g. "eyes" in a single call, so
# this needs far fewer fal calls than one-per-side would.
SECTION_PROMPTS = {
"skin": "facial skin",
"nose": "nose",
"eyes": "eyes",
"eyebrows": "eyebrows",
"ears": "ears",
"mouth": "mouth",
"lips": "lips",
"hair": "hair",
"hat": "hat",
# "eyeglasses" (unqualified) reads as the whole glasses silhouette,
# frame + lens together, to SAM 3 - which pulls the visible under-eye
# skin behind the lens into the exclusion too, so that skin never gets
# retouched. Worded to target the rigid frame/temples specifically;
# overridable per-run via glasses_prompt_override below without editing
# code, since how well this text-only distinction lands can vary by
# glasses style/photo.
"glasses": "eyeglasses frame only - the metal or plastic rim and temples, not the lens or the skin behind it",
"earrings": "earrings",
"neck": "neck",
"necklace": "necklace",
"clothing": "clothing",
}
# Defaults mirror the production values baked into the original local
# FaceParsingResultsParser "EXCLUSION" node in the re-skin workflow: nose,
# eyes, ears, mouth, lips, hair and hat were excluded from retouching;
# skin, eyebrows, neck and clothing were left in (retouched).
#
# "glasses" is added on top of that original set (the old 19-class local
# face-parsing model had no eyeglasses class at all, so it couldn't have
# been in that baseline). Rigid, thin, high-contrast objects like glasses
# frames are exactly what a diffusion inpaint pass struggles to reproduce
# pixel-for-pixel - if "glasses" isn't excluded, fal regenerates that area
# too and the result is a ghosted/doubled frame in the final composite,
# since nothing pastes the true original glasses pixels back afterward.
DEFAULT_ON = {"nose", "eyes", "ears", "mouth", "lips", "hair", "hat", "glasses"}
# One distinct color per section, purely for the color_preview visualization
# (like the old local FaceParsing node's colorized class overlay) - the
# actual "mask" output stays a plain grayscale merge, unaffected by this.
SECTION_COLORS = {
"skin": (255, 0, 0),
"nose": (0, 255, 0),
"eyes": (0, 0, 255),
"eyebrows": (255, 255, 0),
"ears": (255, 0, 255),
"mouth": (0, 255, 255),
"lips": (255, 128, 0),
"hair": (128, 0, 255),
"hat": (0, 128, 128),
"glasses": (128, 128, 0),
"earrings": (255, 192, 203),
"neck": (0, 160, 0),
"necklace": (160, 160, 160),
"clothing": (139, 69, 19),
}
class SupersidePortraitSectionsNode(SupersideFalNode, ImageProcessingMixin, APIClientMixin):
"""
Superside Portrait Sections: in-house, fal-based replacement for the
local comfyui_face_parsing package (FaceParsingProcessorLoader +
FaceParsingModelLoader + FaceParse + FaceParsingResultsParser). Lets
you toggle which facial/portrait sections to include in one output
mask - e.g. as an EXCLUSION mask so a retouch pass skips eyes/lips/hair
- using SAM 3 (fal-ai/sam-3/image) instead of a local segmentation
model, one call per *active* toggle (not per class), merged with OR.
Trade-off vs. the original local model: SAM 3 is open-vocabulary and
promptable rather than a fixed, pixel-labeled taxonomy, so the exact
boundary of e.g. "lips" may be slightly less crisp than a dedicated
face-parsing network - and each active toggle is one extra fal.ai
call (cost/latency), unlike the original's single local pass.
"""
@classmethod
def INPUT_TYPES(cls):
toggles = {
name: ("BOOLEAN", {"default": name in DEFAULT_ON})
for name in SECTION_PROMPTS
}
# Per-section opacity: how strongly each section is written into the
# exclusion mask. 1.0 (default) = fully white = the downstream composite
# pastes 100% of the ORIGINAL back there (no retouch) - identical to the
# old binary behavior. 0.5 = gray = composite blends 50% original / 50%
# generated, i.e. a partial retouch on that section (e.g. clothing).
# Only applies when the section's toggle is ON.
opacities = {
f"{name}_opacity": (
"FLOAT",
{
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": (
f"Opacity of '{name}' in the exclusion mask (only when '{name}' is ON). "
"1.0 = fully preserve the original here (no retouch). 0.5 = partial "
"(50% original / 50% generated in the final composite). 0.0 = no exclusion."
),
},
)
for name in SECTION_PROMPTS
}
return {
"required": {
"image": ("IMAGE",),
"api_key": API_KEY_INPUT_SPEC,
},
"optional": {
**toggles,
**opacities,
"glasses_prompt_override": (
"STRING",
{
"multiline": False,
"default": SECTION_PROMPTS["glasses"],
"tooltip": (
"SAM 3 text query used for the 'glasses' toggle above. Edit "
"this to tune how tightly it targets just the frame/temples "
"vs. the whole glasses silhouette (frame+lens) - no code "
"changes needed. Only used when 'glasses' is ON."
),
},
),
"glasses_box_center_x": (
"INT",
{
"default": -1,
"min": -1,
"max": 1_000_000,
"tooltip": (
"Optional grounding box for the 'glasses' section, in the "
"old GroundingDINO+SAM style: wire in a Florence-2 Smart "
"Region Selector's center_x/center_y/crop_width/crop_height "
"(query e.g. 'eyeglasses frame') and SAM 3 will refine that "
"box into a precise mask instead of relying on text alone. "
"Leave at -1 (unconnected) to use text-only prompting."
),
},
),
"glasses_box_center_y": ("INT", {"default": -1, "min": -1, "max": 1_000_000}),
"glasses_box_width": ("INT", {"default": -1, "min": -1, "max": 1_000_000}),
"glasses_box_height": ("INT", {"default": -1, "min": -1, "max": 1_000_000}),
"padding_percent": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 50.0, "step": 0.5}),
"partial_feather_percent": (
"FLOAT",
{
"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.5,
"tooltip": (
"Feather (soft edge) applied ONLY to sections whose opacity is "
"below 1.0 (e.g. clothing at 0.5). Softens the partial region's "
"border so the downstream composite blends it gradually instead "
"of showing a hard line / doubled edge. Full-opacity (1.0) "
"sections are untouched here - they keep their crisp edge for the "
"downstream grow/blur to handle. 0 = off."
),
},
),
"partial_contract_percent": (
"FLOAT",
{
"default": 0.0, "min": 0.0, "max": 25.0, "step": 0.5,
"tooltip": (
"Contract (erode inward) the mask of partial-opacity sections "
"BEFORE feathering, so the soft edge sits INSIDE the region "
"instead of bleeding outward past its true border (which shows as "
"a faint ring/edge on the surrounding skin). Set it near "
"partial_feather_percent to pull the feathered ramp back to the "
"real edge. Only affects partial (<1.0 opacity) sections. 0 = off."
),
},
),
},
}
RETURN_TYPES = ("MASK", "STRING", "IMAGE")
RETURN_NAMES = ("mask", "info", "color_preview")
FUNCTION = "build_mask"
DESCRIPTION = (
"Toggle which portrait sections (eyes, lips, hair, ...) to include "
"in one merged mask, using SAM 3 on fal.ai - in-house replacement "
"for the local face-parsing model's per-region checklist."
)
@staticmethod
def _scale_opacity(mask_uint8, opacity, feather_px=0.0, contract_px=0.0):
"""Scale a 0/255 section mask by a 0-1 opacity, optionally contracting
(eroding inward) then feathering the edge - only meaningful for partial
sections. opacity>=1 -> unchanged (identical to the old binary behavior)."""
if opacity >= 1.0:
return mask_uint8
work = mask_uint8.astype(np.float32)
if contract_px and contract_px > 0:
from scipy.ndimage import grey_erosion
k = int(round(contract_px)) * 2 + 1
work = grey_erosion(work, size=(k, k), mode="constant", cval=0.0)
scaled = work * max(0.0, float(opacity))
if feather_px and feather_px > 0:
from scipy.ndimage import gaussian_filter
scaled = gaussian_filter(scaled, sigma=float(feather_px))
return np.clip(scaled, 0, 255).astype(np.uint8)
def _download_mask_array(self, image_ref, width, height):
url = image_ref.get("url") if isinstance(image_ref, dict) else image_ref
if not url:
raise RuntimeError("SAM 3 returned a section without a mask URL.")
if str(url).startswith("data:"):
header, _, payload = url.partition(",")
content = base64.b64decode(payload)
else:
response = requests.get(url, timeout=300)
response.raise_for_status()
content = response.content
pil_image = Image.open(io.BytesIO(content))
pil_image.load()
if pil_image.mode == "RGBA":
arr = np.asarray(pil_image.getchannel("A"), dtype=np.uint8)
else:
arr = np.asarray(pil_image.convert("L"), dtype=np.uint8)
if arr.shape[:2] != (height, width):
arr = np.asarray(
Image.fromarray(arr, mode="L").resize((width, height), Image.BILINEAR), dtype=np.uint8
)
return arr
def _sam3_mask_for_prompt(self, client, image_url, prompt, width, height, box_prompts=None):
result = self.call_api(
client,
"fal-ai/sam-3/image",
{
"image_url": image_url,
"prompt": prompt,
"point_prompts": [],
# A box here reproduces the old GroundingDINO+SAM two-stage
# pattern: the box (e.g. from Florence-2 grounding) tells SAM
# 3 exactly where to look, so it refines that region into a
# precise mask instead of relying on text alone to both find
# AND segment the target - text-only tends to grab the whole
# object (e.g. glasses lens+frame) when a sub-part is meant.
"box_prompts": box_prompts or [],
"apply_mask": False,
"output_format": "png",
"return_multiple_masks": True,
"max_masks": 8,
"include_scores": False,
"include_boxes": False,
},
)
entries = result.get("masks") if isinstance(result, dict) else None
if not entries:
primary = result.get("image") if isinstance(result, dict) else None
entries = [primary] if primary else []
merged = np.zeros((height, width), dtype=np.uint8)
for entry in entries:
try:
arr = self._download_mask_array(entry, width, height)
except Exception as e:
logger.warning(f"SAM 3 mask download failed for prompt '{prompt}': {e}")
continue
merged = np.maximum(merged, arr)
return merged
def build_mask(
self,
image,
api_key,
padding_percent=0.0,
partial_feather_percent=0.0,
partial_contract_percent=0.0,
glasses_prompt_override="",
glasses_box_center_x=-1,
glasses_box_center_y=-1,
glasses_box_width=-1,
glasses_box_height=-1,
**toggles,
):
try:
client = self.get_client(api_key)
if image.ndim != 4 or image.shape[0] != 1:
raise ValueError("Superside Portrait Sections currently supports batch size 1 only.")
height, width = image.shape[1], image.shape[2]
image_url = self.upload_image(client, image, max_dimension=2048)
active = [name for name in SECTION_PROMPTS if toggles.get(name, False)]
# Optional GroundingDINO+SAM-style hint for "glasses": a box from
# an upstream grounding model (e.g. Florence-2 Smart Region
# Selector with a "eyeglasses frame" query) tells SAM 3 exactly
# where to look, so it refines that box into a mask rather than
# having to both find AND segment the frame from text alone.
glasses_box_prompts = []
if (
glasses_box_center_x >= 0
and glasses_box_center_y >= 0
and glasses_box_width > 0
and glasses_box_height > 0
):
x1 = max(0, glasses_box_center_x - glasses_box_width // 2)
y1 = max(0, glasses_box_center_y - glasses_box_height // 2)
x2 = min(width, glasses_box_center_x + glasses_box_width // 2)
y2 = min(height, glasses_box_center_y + glasses_box_height // 2)
# fal's box_prompts schema is a list of BoxPrompt *objects*
# (x_min/y_min/x_max/y_max), not plain [x1,y1,x2,y2] arrays -
# sending arrays 422s ("Input should be a valid dictionary or
# object"), which was silently swallowed by the try/except
# below and skipped the whole 'glasses' section (no box, no
# text fallback), so nothing ever got excluded.
glasses_box_prompts = [{"x_min": x1, "y_min": y1, "x_max": x2, "y_max": y2}]
# Feather / contract (in px) for partial sections, from the smaller edge.
feather_px = min(width, height) * float(partial_feather_percent) / 100.0
contract_px = min(width, height) * float(partial_contract_percent) / 100.0
merged = np.zeros((height, width), dtype=np.uint8)
used = []
section_masks = [] # [(name, mask_uint8), ...] - kept for color_preview
for name in active:
prompt = SECTION_PROMPTS[name]
if name == "glasses" and glasses_prompt_override and glasses_prompt_override.strip():
prompt = glasses_prompt_override.strip()
box_prompts = glasses_box_prompts if name == "glasses" else []
opacity = float(toggles.get(f"{name}_opacity", 1.0))
try:
section_mask = self._sam3_mask_for_prompt(
client, image_url, prompt, width, height, box_prompts=box_prompts
)
except Exception as e:
if box_prompts:
# Don't let a bad/failed box silently drop the whole
# section - fall back to text-only prompting so
# "glasses" still gets excluded even if the grounding
# box itself was rejected or came back empty.
logger.warning(
f"Section '{name}': box-prompted SAM 3 call failed ({e}); "
f"retrying with text-only prompt."
)
try:
section_mask = self._sam3_mask_for_prompt(
client, image_url, prompt, width, height, box_prompts=[]
)
merged = np.maximum(merged, self._scale_opacity(section_mask, opacity, feather_px, contract_px))
used.append(name)
section_masks.append((name, section_mask))
continue
except Exception as e2:
e = e2
logger.warning(f"Skipping section '{name}' (SAM 3 call failed): {e}")
continue
merged = np.maximum(merged, self._scale_opacity(section_mask, opacity, feather_px, contract_px))
used.append(name)
section_masks.append((name, section_mask))
# Colorized overlay preview - one distinct color per section,
# blended over the original image. Purely visual (like the old
# local FaceParsing node's colored class map); the real "mask"
# output above stays a plain grayscale merge.
base_np = image[0].detach().cpu().numpy()
if base_np.max() <= 1.0:
base_np = base_np * 255.0
preview_np = np.clip(base_np, 0, 255).astype(np.float32)[:, :, :3].copy()
alpha = 0.5
for name, section_mask in section_masks:
color = np.array(SECTION_COLORS.get(name, (255, 255, 255)), dtype=np.float32)
m = (section_mask.astype(np.float32) / 255.0) * alpha
m3 = m[:, :, None]
preview_np = preview_np * (1.0 - m3) + color[None, None, :] * m3
preview_tensor = torch.from_numpy(np.clip(preview_np, 0, 255).astype(np.float32) / 255.0).unsqueeze(0)
if padding_percent > 0 and merged.max() > 0:
# Uniform grow of the mask. Grayscale dilation with the same
# cross structuring element / iteration count the old
# binary_dilation used, so the grown SHAPE is unchanged and it's
# byte-identical on a binary mask - but partial (per-section
# opacity) gray values are now preserved through the grow.
from scipy.ndimage import grey_dilation
pad_px = int(round(min(width, height) * padding_percent / 100.0))
if pad_px > 0:
cross = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool)
for _ in range(pad_px):
merged = grey_dilation(merged, footprint=cross, mode="constant", cval=0)
mask_float = np.clip(merged, 0, 255).astype(np.float32) / 255.0
mask_tensor = torch.from_numpy(mask_float).unsqueeze(0)
info = json.dumps({"active_sections": used, "padding_percent": padding_percent})
return (mask_tensor, info, preview_tensor)
except Exception as e:
logger.error(f"Portrait sections failed: {str(e)}")
raise RuntimeError(f"Portrait sections failed: {str(e)}") from e