- Fix "Behind Subject" not occluding: blur the two halves separately and re-apply the silhouette after, add a traced cast shadow (shadow_strength, shadow_length), and give the background glow real radial falloff - Replace use_colored_lights with lighting_mode, adding "Both"; three presets set a colour and a grading block, so 12 values in each were inert - Rename use_gradient_mode to mask_shape, and collapse apply_3d_lighting plus light_direction into subject_interaction - Remove show_debug_info: wiring debug_image is the whole gesture, tracked through a hidden debug_output_connected input so one run is enough - Scale everything drawn on the debug view with the frame, and flip a light's label to the left of the marker when it would run off the right edge - Show only the controls that are doing something; grey out what a preset owns and put the preset's value in the label - Ship a working "Fix node (recreate)" and remove ComfyUI-Manager's broken one - Remap pre-v4 saves by name in web/relight_migrate.js - Make "Rim Light (Behind)" a neutral white rim instead of (200,255,200) - Drop requirements.txt; numpy, Pillow, scipy and torch come with ComfyUI - Add a frontend test suite driving the real web/*.js outside a browser, and a CI job for it - Bump to 4.0.0
1296 lines
74 KiB
Python
1296 lines
74 KiB
Python
import logging
|
|
import math
|
|
import time
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
# SciPy is required for rim lighting, occlusion smoothing and mask blur
|
|
from scipy import ndimage
|
|
|
|
# Import the ComfyUI v3 node API. `comfy_api.v0_0_2` is a stable alias for the
|
|
# same surface; fall back to `latest` on builds that predate it, and fail with a
|
|
# readable message rather than a bare traceback if neither exists.
|
|
try:
|
|
from comfy_api.v0_0_2 import ComfyExtension, io
|
|
except ImportError:
|
|
try:
|
|
from comfy_api.latest import ComfyExtension, io
|
|
except ImportError as exc: # pragma: no cover - depends on the ComfyUI build
|
|
raise ImportError(
|
|
"ComfyUI-ReLight requires the ComfyUI v3 node API (comfy_api), introduced in "
|
|
"ComfyUI 0.3.48. Please update ComfyUI."
|
|
) from exc
|
|
|
|
logger = logging.getLogger("ComfyUI-ReLight")
|
|
|
|
# PIL's 'L' conversion weights, kept so contrast/saturation match the look this
|
|
# node had when those operations went through ImageEnhance.
|
|
_LUMA_WEIGHTS = (0.299, 0.587, 0.114)
|
|
|
|
# Shared by the gamma widgets' min/max and the strength-scaling clamp in
|
|
# _scaled_gamma, so the two can never drift apart.
|
|
GAMMA_MIN = 0.1
|
|
GAMMA_MAX = 5.0
|
|
|
|
# Single-entry pixel-coordinate cache; every light in a run shares one grid.
|
|
#
|
|
# Module level, deliberately: ComfyUI's v3 runtime does not call execute() on
|
|
# this class. It calls it on a *locked clone* whose metaclass raises
|
|
# AttributeError on any class-attribute write, so `cls._coord_cache = ...` blew
|
|
# up on the first mask of every run. Never store per-run state on the class.
|
|
_COORD_CACHE = {}
|
|
|
|
# The cast-shadow trace runs at or below this resolution and is upsampled.
|
|
# The result is blurred immediately afterwards, so tracing a 4K frame at full
|
|
# size buys nothing visible and costs 60x the work of tracing it at 512.
|
|
_SHADOW_TRACE_MAX = 512
|
|
|
|
|
|
def _supports_advanced():
|
|
"""Whether this ComfyUI build accepts `advanced=` on widget inputs.
|
|
|
|
The flag arrived long after the v3 API itself. Passing it to an older build
|
|
raises TypeError at import time, which takes the whole node pack down, so
|
|
probe for it instead of assuming.
|
|
"""
|
|
try:
|
|
io.Boolean.Input("_relight_probe", default=False, advanced=True)
|
|
except TypeError:
|
|
return False
|
|
return True
|
|
|
|
|
|
_ADVANCED_SUPPORTED = _supports_advanced()
|
|
|
|
|
|
def _wrap_debug_text(draw, text, font, max_width):
|
|
"""Greedy word wrap for the debug placeholder, in pixels not characters."""
|
|
words, lines, line = text.split(), [], ""
|
|
for word in words:
|
|
candidate = f"{line} {word}".strip()
|
|
try:
|
|
too_wide = draw.textlength(candidate, font=font) > max_width
|
|
except Exception:
|
|
too_wide = len(candidate) * 7 > max_width
|
|
if too_wide and line:
|
|
lines.append(line)
|
|
line = word
|
|
else:
|
|
line = candidate
|
|
if line:
|
|
lines.append(line)
|
|
return lines
|
|
|
|
|
|
def _debug_font_size(height):
|
|
"""Type size for the debug overlays, as a fraction of the frame height.
|
|
|
|
Fixed 13px type is legible on the 96x64 fixture the tests used and invisible
|
|
on a real 1300px render - 1.7% of the height, which inside a ComfyUI preview
|
|
thumbnail is a dark rectangle. That is exactly how the v3.1.2 placeholder
|
|
shipped "working" and was reported as a black frame. Scale with the frame,
|
|
with a floor so small images stay readable and a cap so a 4K render does not
|
|
get billboard type.
|
|
"""
|
|
return int(min(64, max(13, round(height * 0.035))))
|
|
|
|
|
|
def _load_debug_font(size):
|
|
"""The default font at `size`, falling back through older Pillow APIs."""
|
|
try:
|
|
return ImageFont.load_default(size=size)
|
|
except Exception:
|
|
try:
|
|
return ImageFont.load_default()
|
|
except Exception as err: # pragma: no cover - no usable font at all
|
|
logger.debug(f" Could not load a default font: {err}")
|
|
return None
|
|
|
|
|
|
def _adv(**kwargs):
|
|
"""Pass input kwargs through, dropping `advanced` where unsupported."""
|
|
if not _ADVANCED_SUPPORTED:
|
|
kwargs.pop("advanced", None)
|
|
return kwargs
|
|
|
|
|
|
class ReLight(io.ComfyNode):
|
|
"""
|
|
Creates realistic lighting effects by applying color corrections or colored light
|
|
to distinct areas of an image. Supports multiple light sources, colored lights,
|
|
and 3D lighting simulation with subject occlusion. Requires SciPy.
|
|
"""
|
|
|
|
# --- Mode vocabularies -------------------------------------------------
|
|
# v4.0.0 replaced four booleans (use_colored_lights, use_gradient_mode,
|
|
# apply_3d_lighting, show_debug_info) with these named choices. The option
|
|
# strings are part of the saved-workflow format and are mirrored in
|
|
# web/relight_migrate.js - change one and you must change the other.
|
|
MODE_CORRECTION = "Color Correction"
|
|
MODE_COLORED = "Colored Light"
|
|
MODE_BOTH = "Both"
|
|
LIGHTING_MODES = (MODE_CORRECTION, MODE_COLORED, MODE_BOTH)
|
|
|
|
SHAPE_RADIAL = "Radial falloff"
|
|
SHAPE_GRADIENT = "Directional gradient"
|
|
MASK_SHAPES = (SHAPE_RADIAL, SHAPE_GRADIENT)
|
|
|
|
SUBJECT_NONE = "None"
|
|
SUBJECT_FRONT = "Light in front of subject"
|
|
SUBJECT_RIM = "Light behind subject (rim)"
|
|
SUBJECT_INTERACTIONS = (SUBJECT_NONE, SUBJECT_FRONT, SUBJECT_RIM)
|
|
|
|
# Gamma follows the display convention used by Photoshop's Levels midtone
|
|
# slider, ImageMagick's -gamma and ffmpeg's eq filter: output = input^(1/gamma),
|
|
# so values above 1.0 brighten and values below 1.0 darken. Preset gammas below
|
|
# are written in those terms - a shaded outer zone gets a gamma under 1.0.
|
|
PRESETS = {
|
|
"None": {},
|
|
"Soft Window Light": {
|
|
"light_position_x": 0.7, "light_position_y": 0.3, "inner_circle_radius": 0.45, "outer_circle_radius": 0.8,
|
|
"inner_brightness": 10, "inner_contrast": 0, "inner_saturation": 0, "inner_temperature": -5, "inner_tint": 0, "inner_gamma": 1.0,
|
|
"outer_brightness": -15, "outer_contrast": 0, "outer_saturation": -10, "outer_temperature": -5, "outer_tint": 0, "outer_gamma": 0.83,
|
|
"mask_blur": 80, "rim_amplification": 1.0
|
|
},
|
|
"Dramatic Side Light": {
|
|
"light_position_x": 0.1, "light_position_y": 0.5, "inner_circle_radius": 0.3, "outer_circle_radius": 0.6,
|
|
"inner_brightness": 15, "inner_contrast": 10, "inner_saturation": 10, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.11,
|
|
"outer_brightness": -30, "outer_contrast": 10, "outer_saturation": -20, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 0.77,
|
|
"mask_blur": 50, "rim_amplification": 1.0
|
|
},
|
|
"Warm Sunset Glow": {
|
|
"light_position_x": 0.9, "light_position_y": 0.4, "inner_circle_radius": 0.5, "outer_circle_radius": 0.8,
|
|
"inner_brightness": 3, "inner_contrast": 5, "inner_saturation": 10, "inner_temperature": 25, "inner_tint": -5, "inner_gamma": 1.05,
|
|
"outer_brightness": -10, "outer_contrast": 0, "outer_saturation": -5, "outer_temperature": 15, "outer_tint": -5, "outer_gamma": 0.91,
|
|
"mask_blur": 75, "lighting_mode": MODE_BOTH, "light_color_r": 255, "light_color_g": 200, "light_color_b": 120,
|
|
"light_intensity": 0.45, "rim_amplification": 1.0, "mask_shape": SHAPE_GRADIENT
|
|
},
|
|
"Cool Blue Moonlight": {
|
|
"light_position_x": 0.8, "light_position_y": 0.2, "inner_circle_radius": 0.4, "outer_circle_radius": 0.7,
|
|
"inner_brightness": -5, "inner_contrast": 5, "inner_saturation": -5, "inner_temperature": -20, "inner_tint": 0, "inner_gamma": 0.91,
|
|
"outer_brightness": -20, "outer_contrast": 0, "outer_saturation": -10, "outer_temperature": -30, "outer_tint": 0, "outer_gamma": 0.83,
|
|
"mask_blur": 60, "lighting_mode": MODE_BOTH, "light_color_r": 120, "light_color_g": 150, "light_color_b": 255,
|
|
"light_intensity": 0.35, "rim_amplification": 1.0
|
|
},
|
|
"Studio Key Light": {
|
|
"light_position_x": 0.4, "light_position_y": 0.3, "inner_circle_radius": 0.6, "outer_circle_radius": 0.9,
|
|
"inner_brightness": 12, "inner_contrast": 5, "inner_saturation": 0, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.0,
|
|
"outer_brightness": -5, "outer_contrast": 0, "outer_saturation": -5, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 0.91,
|
|
"mask_blur": 90, "rim_amplification": 1.0
|
|
},
|
|
"Rim Light (Behind)": {
|
|
"light_position_x": 0.5, "light_position_y": 0.1, "inner_circle_radius": 0.3, "outer_circle_radius": 0.6,
|
|
"subject_interaction": SUBJECT_RIM, "lighting_mode": MODE_BOTH,
|
|
# Neutral white. This shipped as (200, 255, 200) from v1.0, which put a
|
|
# +20/255 green cast on the rim and the background glow - invisible while
|
|
# the grading block was being discarded, obvious once inner_saturation
|
|
# landed. Intensity drops 1.2 -> 1.0 so removing the tint does not also
|
|
# make the preset brighter: mean lift stays within 2% of what shipped.
|
|
"light_color_r": 255, "light_color_g": 255, "light_color_b": 255, "light_intensity": 1.0,
|
|
"inner_brightness": 0, "inner_contrast": 8, "inner_saturation": 12, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.0,
|
|
"outer_brightness": 0, "outer_contrast": 0, "outer_saturation": 0, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 1.0,
|
|
"mask_blur": 25, "effect_strength": 1.5, "rim_amplification": 2.5,
|
|
"shadow_strength": 0.7, "shadow_length": 0.45
|
|
},
|
|
"Spotlight": {
|
|
"light_position_x": 0.5, "light_position_y": 0.4, "inner_circle_radius": 0.1, "outer_circle_radius": 0.25,
|
|
"inner_brightness": 25, "inner_contrast": 15, "inner_saturation": -5, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 1.11,
|
|
"outer_brightness": -40, "outer_contrast": 10, "outer_saturation": -20, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 0.77,
|
|
"mask_blur": 30, "rim_amplification": 1.0, "effect_strength": 1.2
|
|
},
|
|
"Negative Light (Darken)": {
|
|
"light_position_x": 0.5, "light_position_y": 0.5, "inner_circle_radius": 0.4, "outer_circle_radius": 0.7,
|
|
"inner_brightness": -20, "inner_contrast": 5, "inner_saturation": -5, "inner_temperature": 0, "inner_tint": 0, "inner_gamma": 0.91,
|
|
"outer_brightness": 0, "outer_contrast": 0, "outer_saturation": 0, "outer_temperature": 0, "outer_tint": 0, "outer_gamma": 1.0,
|
|
"mask_blur": 60, "rim_amplification": 1.0, "effect_strength": 1.0
|
|
}
|
|
}
|
|
|
|
# Three presets ("Warm Sunset Glow", "Cool Blue Moonlight", "Rim Light
|
|
# (Behind)") set a colour *and* a grading block. Up to v3.1.2 the two were
|
|
# mutually exclusive, so 12 values in each did nothing; they now run as
|
|
# MODE_BOTH, and the colour intensities were pulled down to compensate for
|
|
# the grade no longer being discarded.
|
|
|
|
# Scaled by the user's widget rather than overridden outright; see _load_preset.
|
|
STRENGTH_KEY = "effect_strength"
|
|
|
|
# Keys a preset may not touch while `preserve_positioning` is on.
|
|
GEOMETRY_KEYS = frozenset({
|
|
"light_position_x", "light_position_y", "inner_circle_radius", "outer_circle_radius",
|
|
"light2_position_x", "light2_position_y", "light2_inner_radius", "light2_outer_radius",
|
|
"light3_position_x", "light3_position_y", "light3_inner_radius", "light3_outer_radius",
|
|
})
|
|
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
"""Define node schema for ComfyUI v3.
|
|
|
|
Widget order is grouped for reading, not frozen: saved workflows store
|
|
widget values *positionally*, and what keeps pre-v4 files loading is
|
|
``web/relight_migrate.js``, which remaps them by name. Any change to
|
|
this list - adding, removing, renaming or reordering - must be paired
|
|
with a check that the migration still produces the right mapping, and
|
|
with the legacy order pinned in ``tests/test_relight.py``.
|
|
"""
|
|
return io.Schema(
|
|
node_id="ReLight",
|
|
display_name="ReLight 💡",
|
|
category="image/lighting",
|
|
description="Creates realistic lighting effects with multiple light sources, colored lights, and 3D lighting simulation with subject occlusion",
|
|
hidden=[io.Hidden.prompt, io.Hidden.unique_id],
|
|
inputs=[
|
|
# --- Core Inputs ---
|
|
io.Image.Input("image", tooltip="The input image to apply lighting effects to"),
|
|
io.Mask.Input("mask", optional=True, tooltip="Foreground mask (white=subject, black=background). Needed for subject interaction (in front of / behind the subject) and for remove_background compositing. Resized automatically if it does not match the image"),
|
|
|
|
# --- Preset ---
|
|
io.Combo.Input("preset", options=list(cls.PRESETS.keys()), default="None", tooltip="Select a preset or None for custom settings. NOTE: a preset overrides the widgets below - the values shown on the node are ignored for whatever the preset defines. The one exception is effect_strength, which scales the preset instead of being replaced by it"),
|
|
io.Boolean.Input("preserve_positioning", **_adv(default=False, advanced=True, tooltip="Keep your own light positions and radii when a preset is selected, instead of letting the preset set them")),
|
|
|
|
# --- Mode ---
|
|
io.Combo.Input("lighting_mode", options=list(cls.LIGHTING_MODES), default=cls.MODE_CORRECTION, tooltip="What the light does. Color Correction grades an inner zone and the ring around it. Colored Light adds coloured light on top of the image. Both applies the coloured light first and then the grade"),
|
|
io.Combo.Input("mask_shape", options=list(cls.MASK_SHAPES), default=cls.SHAPE_RADIAL, tooltip="Shape of the light mask. Radial falloff is a lamp; Directional gradient is light arriving from one side (sunset rays, window light)"),
|
|
|
|
# --- Subject interaction ---
|
|
io.Combo.Input("subject_interaction", options=list(cls.SUBJECT_INTERACTIONS), default=cls.SUBJECT_NONE, tooltip="How the light interacts with the masked subject. None lights the whole frame evenly. The other two need a mask connected"),
|
|
io.Boolean.Input("remove_background", default=False, tooltip="Composite the lit result back over the untouched original using the mask, so only the subject is relit. Does not remove anything. Ignored when the light is in front of or behind the subject"),
|
|
|
|
# --- Global modifiers ---
|
|
io.Int.Input("num_light_sources", default=1, min=1, max=3, step=1, tooltip="Number of light sources (1-3). Lights 2 and 3 have their own position, radius and color, but in color-correction mode they reuse Light 1 correction settings"),
|
|
io.Float.Input("effect_strength", default=1.0, min=0.0, max=5.0, step=0.1, tooltip="Overall intensity multiplier for lighting adjustments/colors, gamma included. 0.0 leaves the image untouched. With a preset active this scales the preset own strength, so 1.0 is the preset as designed. Does not scale rim_amplification or mask_blur - those have their own controls"),
|
|
io.Float.Input("mask_blur", default=50.0, min=0.0, max=200.0, step=1.0, tooltip="Blur radius for light mask edges (smoother transitions)"),
|
|
io.Float.Input("rim_amplification", default=2.0, min=0.0, max=10.0, step=0.1, tooltip="Intensity boost for the rim highlight along the subject edge. Only used when the light is behind the subject"),
|
|
io.Float.Input("shadow_strength", default=0.6, min=0.0, max=1.0, step=0.05, tooltip="How dark the shadow the subject casts across the background is. 0.0 casts no shadow. Only used when the light is behind the subject"),
|
|
io.Float.Input("shadow_length", default=0.35, min=0.0, max=1.0, step=0.01, tooltip="How far the cast shadow reaches, as a fraction of the image shorter side. Only used when the light is behind the subject"),
|
|
io.Boolean.Input("debug_output_connected", **_adv(default=False, optional=True, advanced=True, tooltip="Managed by ReLight itself - the node UI hides this and keeps it in step with whether the debug_image output is wired to anything. It exists because a widget value is what ComfyUI hashes to decide a node needs re-running, so without it, wiring the debug output after a run would just replay the cached placeholder. Nothing to set by hand")),
|
|
|
|
# --- Light 1: position and shape ---
|
|
io.Float.Input("light_position_x", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Horizontal position (0=left, 1=right)"),
|
|
io.Float.Input("light_position_y", default=0.5, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Vertical position (0=top, 1=bottom)"),
|
|
io.Float.Input("inner_circle_radius", default=0.4, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Inner radius (strongest effect area)"),
|
|
io.Float.Input("outer_circle_radius", default=0.7, min=0.0, max=1.0, step=0.01, tooltip="Light 1: Outer radius (falloff area)"),
|
|
|
|
# --- Light 1: colour (Colored Light / Both) ---
|
|
io.Int.Input("light_color_r", default=255, min=0, max=255, step=1, tooltip="Light 1: Red color (Colored Light / Both)"),
|
|
io.Int.Input("light_color_g", default=255, min=0, max=255, step=1, tooltip="Light 1: Green color (Colored Light / Both)"),
|
|
io.Int.Input("light_color_b", default=255, min=0, max=255, step=1, tooltip="Light 1: Blue color (Colored Light / Both)"),
|
|
io.Float.Input("light_intensity", default=1.0, min=0.0, max=3.0, step=0.1, tooltip="Light 1: Intensity (Colored Light / Both)"),
|
|
|
|
# --- Light 1: grading, inner zone (Color Correction / Both) ---
|
|
io.Float.Input("inner_brightness", default=10.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area brightness (Color Correction / Both)"),
|
|
io.Float.Input("inner_contrast", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area contrast (Color Correction / Both)"),
|
|
io.Float.Input("inner_saturation", default=5.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area saturation (Color Correction / Both)"),
|
|
io.Float.Input("inner_temperature", default=0.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area temperature (-100=cool, 100=warm)"),
|
|
io.Float.Input("inner_tint", default=0.0, min=-100.0, max=100.0, step=1.0, tooltip="Light 1: Inner area tint (-100=magenta, 100=green)"),
|
|
io.Float.Input("inner_gamma", default=1.0, min=GAMMA_MIN, max=GAMMA_MAX, step=0.05, tooltip="Light 1: Inner area gamma. Above 1.0 brightens midtones, below 1.0 darkens them"),
|
|
|
|
# --- Light 1: grading, outer ring (Color Correction / Both) ---
|
|
io.Float.Input("outer_brightness", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area brightness (Color Correction / Both)")),
|
|
io.Float.Input("outer_contrast", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area contrast (Color Correction / Both)")),
|
|
io.Float.Input("outer_saturation", **_adv(default=-10.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area saturation (Color Correction / Both)")),
|
|
io.Float.Input("outer_temperature", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area temperature")),
|
|
io.Float.Input("outer_tint", **_adv(default=0.0, min=-100.0, max=100.0, step=1.0, advanced=True, tooltip="Light 1: Outer area tint")),
|
|
io.Float.Input("outer_gamma", **_adv(default=0.91, min=GAMMA_MIN, max=GAMMA_MAX, step=0.05, advanced=True, tooltip="Light 1: Outer area gamma. Above 1.0 brightens midtones, below 1.0 darkens them")),
|
|
|
|
# --- Light 2 Settings (Optional) ---
|
|
io.Float.Input("light2_position_x", **_adv(default=0.8, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 2: Horizontal position")),
|
|
io.Float.Input("light2_position_y", **_adv(default=0.2, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 2: Vertical position")),
|
|
io.Float.Input("light2_inner_radius", **_adv(default=0.3, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 2: Inner radius")),
|
|
io.Float.Input("light2_outer_radius", **_adv(default=0.6, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 2: Outer radius")),
|
|
io.Int.Input("light2_color_r", **_adv(default=180, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Red color")),
|
|
io.Int.Input("light2_color_g", **_adv(default=180, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Green color")),
|
|
io.Int.Input("light2_color_b", **_adv(default=255, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 2: Blue color")),
|
|
io.Float.Input("light2_intensity", **_adv(default=0.7, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 2: Intensity (Colored Light / Both)")),
|
|
|
|
# --- Light 3 Settings (Optional) ---
|
|
io.Float.Input("light3_position_x", **_adv(default=0.3, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 3: Horizontal position")),
|
|
io.Float.Input("light3_position_y", **_adv(default=0.8, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 3: Vertical position")),
|
|
io.Float.Input("light3_inner_radius", **_adv(default=0.25, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 3: Inner radius")),
|
|
io.Float.Input("light3_outer_radius", **_adv(default=0.5, min=0.0, max=1.0, step=0.01, optional=True, advanced=True, tooltip="Light 3: Outer radius")),
|
|
io.Int.Input("light3_color_r", **_adv(default=255, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Red color")),
|
|
io.Int.Input("light3_color_g", **_adv(default=150, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Green color")),
|
|
io.Int.Input("light3_color_b", **_adv(default=120, min=0, max=255, step=1, optional=True, advanced=True, tooltip="Light 3: Blue color")),
|
|
io.Float.Input("light3_intensity", **_adv(default=0.5, min=0.0, max=3.0, step=0.1, optional=True, advanced=True, tooltip="Light 3: Intensity (Colored Light / Both)")),
|
|
],
|
|
outputs=[
|
|
io.Image.Output("image", display_name="image", tooltip="The relit image"),
|
|
io.Mask.Output("mask", display_name="mask", tooltip="Pass-through of the input mask, normalised to (batch, height, width) and resized to the image (black if none connected)"),
|
|
io.Image.Output("debug_image", display_name="debug_image", tooltip="Visualization of light positions and masks. Drawn whenever this output is connected to something - there is no toggle to remember"),
|
|
]
|
|
)
|
|
|
|
# --- Utility Functions ---
|
|
|
|
@classmethod
|
|
def _load_preset(cls, preset_name, current_params):
|
|
"""Loads preset values, respecting 'preserve_positioning'."""
|
|
if preset_name == "None" or preset_name not in cls.PRESETS:
|
|
return current_params
|
|
logger.debug(f"Applying preset: {preset_name}")
|
|
updated_params = current_params.copy()
|
|
preserve = current_params.get("preserve_positioning", False)
|
|
for key, value in cls.PRESETS[preset_name].items():
|
|
if key not in updated_params:
|
|
continue
|
|
if preserve and key in cls.GEOMETRY_KEYS:
|
|
continue
|
|
if key == cls.STRENGTH_KEY:
|
|
# The master intensity stays under the user's control: the preset
|
|
# supplies a baseline and the widget scales it. Overriding it
|
|
# outright left effect_strength - and its documented "0.0 leaves
|
|
# the image untouched" - dead under the presets that set it.
|
|
updated_params[key] = value * current_params.get(key, 1.0)
|
|
continue
|
|
updated_params[key] = value
|
|
if preserve:
|
|
logger.debug(" - Preserving user-defined light positions and radii.")
|
|
return updated_params
|
|
|
|
@classmethod
|
|
def _pixel_grid(cls, width, height):
|
|
"""Row/column coordinate grids for an image size, reused across lights.
|
|
|
|
Broadcast grids - (height, 1) and (1, width) - rather than two full
|
|
(height, width) arrays. Every expression here combines the two, so numpy
|
|
materialises the same result, but the cached grids stay kilobytes instead
|
|
of megabytes: a 4096x4096 image used to pin 268 MB for the life of the
|
|
process, and each mask cost three full-size temporaries instead of one.
|
|
"""
|
|
key = (height, width)
|
|
cached = _COORD_CACHE.get(key)
|
|
if cached is None:
|
|
cached = np.ogrid[0:height, 0:width]
|
|
# Single-entry cache: a workflow rarely alternates resolutions, and this
|
|
# keeps grids from accumulating. Mutated in place - rebinding the name
|
|
# would need a `global` and buys nothing.
|
|
_COORD_CACHE.clear()
|
|
_COORD_CACHE[key] = cached
|
|
return cached
|
|
|
|
@classmethod
|
|
def create_circle_mask(cls, width, height, center_x, center_y, radius):
|
|
"""Create a circular mask using NumPy."""
|
|
y_coords, x_coords = cls._pixel_grid(width, height)
|
|
center_x_px, center_y_px = center_x * width, center_y * height
|
|
radius_px = radius * min(width, height)
|
|
if radius_px <= 0:
|
|
return torch.zeros((height, width), dtype=torch.float32)
|
|
dist_sq = (x_coords - center_x_px)**2 + (y_coords - center_y_px)**2
|
|
mask = (dist_sq <= radius_px**2).astype(np.float32)
|
|
return torch.from_numpy(mask)
|
|
|
|
@classmethod
|
|
def create_falloff_mask(cls, width, height, center_x, center_y, inner_radius, outer_radius):
|
|
"""Create a radial mask: 1.0 inside inner radius, linear falloff to 0.0 at outer radius."""
|
|
y_coords, x_coords = cls._pixel_grid(width, height)
|
|
center_x_px, center_y_px = center_x * width, center_y * height
|
|
inner_px = max(inner_radius, 0.0) * min(width, height)
|
|
outer_px = max(outer_radius, 0.0) * min(width, height)
|
|
if outer_px <= 0:
|
|
return torch.zeros((height, width), dtype=torch.float32)
|
|
distances = np.sqrt((x_coords - center_x_px)**2 + (y_coords - center_y_px)**2)
|
|
if outer_px <= inner_px:
|
|
# Degenerate ring: hard circle at outer radius
|
|
mask = (distances <= outer_px).astype(np.float32)
|
|
else:
|
|
mask = np.clip((outer_px - distances) / (outer_px - inner_px), 0.0, 1.0).astype(np.float32)
|
|
return torch.from_numpy(mask)
|
|
|
|
@classmethod
|
|
def create_gradient_mask(cls, width, height, center_x, center_y, radius, direction_angle_deg=0):
|
|
"""Create a gradient mask with direction using NumPy."""
|
|
y_coords, x_coords = cls._pixel_grid(width, height)
|
|
center_x_px, center_y_px = center_x * width, center_y * height
|
|
radius_px = radius * min(width, height)
|
|
if radius_px <= 0:
|
|
return torch.zeros((height, width), dtype=torch.float32)
|
|
delta_x, delta_y = x_coords - center_x_px, y_coords - center_y_px
|
|
distances = np.sqrt(delta_x**2 + delta_y**2)
|
|
theta_rad = math.radians(direction_angle_deg)
|
|
dir_x, dir_y = math.cos(theta_rad), math.sin(theta_rad)
|
|
norm_dist = np.where(distances == 0, 1, distances)
|
|
pos_norm_x, pos_norm_y = delta_x / norm_dist, delta_y / norm_dist
|
|
directional_component = pos_norm_x * dir_x + pos_norm_y * dir_y
|
|
gradient_intensity = (directional_component + 1) / 2
|
|
falloff = np.clip(1 - distances / radius_px, 0, 1)
|
|
mask = np.where(distances <= radius_px, falloff * gradient_intensity, 0).astype(np.float32)
|
|
return torch.from_numpy(mask)
|
|
|
|
@classmethod
|
|
def _luma(cls, image_tensor):
|
|
"""Per-pixel luminance, keeping the channel dimension."""
|
|
if image_tensor.shape[-1] == 1:
|
|
return image_tensor
|
|
weights = torch.tensor(_LUMA_WEIGHTS, device=image_tensor.device, dtype=image_tensor.dtype)
|
|
return (image_tensor[..., :3] * weights).sum(dim=-1, keepdim=True)
|
|
|
|
@classmethod
|
|
def apply_color_correction(cls, image_tensor, brightness=0, contrast=0, saturation=0, temperature=0, tint=0, gamma=1.0):
|
|
"""Apply color correction adjustments in float32, on whatever device the image is on.
|
|
|
|
Mirrors the PIL ImageEnhance semantics this node used previously (same
|
|
luma weights, same blend-toward-degenerate maths, same per-stage clamp to
|
|
0-1) without round-tripping through uint8. That round-trip made even an
|
|
all-identity correction shift pixels by 1/255, and the error compounded
|
|
across light sources and across chained ReLight nodes.
|
|
"""
|
|
result = image_tensor
|
|
is_single_channel = result.shape[-1] == 1
|
|
|
|
if abs(brightness) > 0.1:
|
|
result = (result * (1.0 + brightness / 100.0)).clamp(0.0, 1.0)
|
|
|
|
if abs(contrast) > 0.1:
|
|
# PIL blends against a flat grey of the image's mean luminance.
|
|
mean = cls._luma(result).mean(dim=(1, 2, 3), keepdim=True)
|
|
result = (mean + (result - mean) * (1.0 + contrast / 100.0)).clamp(0.0, 1.0)
|
|
|
|
if abs(saturation) > 0.1 and not is_single_channel:
|
|
gray = cls._luma(result)
|
|
result = (gray + (result - gray) * (1.0 + saturation / 100.0)).clamp(0.0, 1.0)
|
|
|
|
if (abs(temperature) > 0.1 or abs(tint) > 0.1) and not is_single_channel:
|
|
gains = torch.tensor(
|
|
[1.0 + temperature / 200.0, 1.0 + tint / 200.0, 1.0 - temperature / 200.0],
|
|
device=result.device, dtype=result.dtype,
|
|
)
|
|
result = (result * gains).clamp(0.0, 1.0)
|
|
|
|
if abs(gamma - 1.0) > 0.01:
|
|
result = result.clamp(0.0, 1.0) ** (1.0 / max(gamma, 0.01))
|
|
|
|
return result.clamp(0.0, 1.0)
|
|
|
|
@staticmethod
|
|
def _gaussian_kernel_1d(radius, sigma, device):
|
|
offsets = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32)
|
|
kernel = torch.exp(-(offsets * offsets) / (2.0 * sigma * sigma))
|
|
return kernel / kernel.sum()
|
|
|
|
@classmethod
|
|
def apply_mask_blur(cls, mask_tensor, blur_amount):
|
|
"""Gaussian-blur a mask as a separable float32 convolution.
|
|
|
|
This used to go through a uint8 PIL image, which quantised the mask to
|
|
255 levels - visible as banding in smooth falloffs, since the mask *is*
|
|
the effect here. Doing it in torch keeps full precision and, on a GPU
|
|
workflow, avoids a device round-trip for every light source. The
|
|
blur_amount -> sigma mapping is unchanged so existing workflows look the
|
|
same.
|
|
"""
|
|
if blur_amount <= 0.1:
|
|
return mask_tensor
|
|
sigma = blur_amount / 5.0
|
|
if sigma <= 0:
|
|
return mask_tensor
|
|
|
|
was_2d = mask_tensor.dim() == 2
|
|
work = mask_tensor.unsqueeze(0) if was_2d else mask_tensor
|
|
work = work.to(dtype=torch.float32).unsqueeze(1) # (batch, 1, height, width)
|
|
height, width = work.shape[-2:]
|
|
device = work.device
|
|
|
|
# Truncate at 3 sigma, and never wider than the axis being blurred -
|
|
# reflect padding cannot exceed the dimension it mirrors.
|
|
radius = max(1, int(3.0 * sigma + 0.5))
|
|
radius_x, radius_y = min(radius, width - 1), min(radius, height - 1)
|
|
|
|
if radius_x > 0:
|
|
kernel_x = cls._gaussian_kernel_1d(radius_x, sigma, device)
|
|
work = F.conv2d(
|
|
F.pad(work, (radius_x, radius_x, 0, 0), mode="reflect"),
|
|
kernel_x.view(1, 1, 1, -1),
|
|
)
|
|
if radius_y > 0:
|
|
kernel_y = cls._gaussian_kernel_1d(radius_y, sigma, device)
|
|
work = F.conv2d(
|
|
F.pad(work, (0, 0, radius_y, radius_y), mode="reflect"),
|
|
kernel_y.view(1, 1, -1, 1),
|
|
)
|
|
|
|
work = work.squeeze(1).clamp(0.0, 1.0)
|
|
if was_2d:
|
|
work = work.squeeze(0)
|
|
return work.to(device=mask_tensor.device, dtype=torch.float32)
|
|
|
|
@classmethod
|
|
def apply_colored_light(cls, image, mask, color_rgb, intensity=1.0):
|
|
"""Apply additive colored light using a mask."""
|
|
if intensity <= 0:
|
|
return image
|
|
color_norm = torch.tensor([c / 255.0 for c in color_rgb], device=image.device, dtype=torch.float32)
|
|
if image.shape[-1] == 1:
|
|
# Single-channel input: add the light's luminance. Broadcasting the RGB
|
|
# triple here would silently turn a 1-channel image into a 3-channel one.
|
|
weights = torch.tensor(_LUMA_WEIGHTS, device=image.device, dtype=torch.float32)
|
|
color_light = (color_norm * weights).sum().view(1, 1, 1, 1)
|
|
else:
|
|
color_light = color_norm.view(1, 1, 1, 3)
|
|
if mask.dim() == 3:
|
|
mask = mask.unsqueeze(-1)
|
|
result = image + color_light * intensity * mask
|
|
return torch.clamp(result, 0.0, 1.0)
|
|
|
|
@classmethod
|
|
def cast_shadow_mask(cls, fg_mask, light_position_x, light_position_y, shadow_length, steps=24):
|
|
"""How much of the subject stands between each pixel and the light.
|
|
|
|
A light behind a subject should leave a shadow across the background,
|
|
and up to v3.1.2 it left none at all: the subject blocked nothing, so
|
|
the near side of a head was lit exactly as brightly as the far side.
|
|
|
|
For every pixel, march back toward the light and take the largest
|
|
foreground value found along the way, out to `shadow_length` of the
|
|
frame's shorter side. A pixel whose path to the light crosses the
|
|
subject comes back 1; one with a clear line of sight comes back 0.
|
|
Marching stops at the light itself, so a pixel closer to the light than
|
|
the subject is never shadowed by it.
|
|
|
|
Vectorised through `grid_sample` rather than a per-pixel loop:
|
|
deterministic, identical on CPU and GPU, and no SciPy. Returns a
|
|
(batch, height, width) mask on the input's device.
|
|
"""
|
|
batch, height, width = fg_mask.shape
|
|
device = fg_mask.device
|
|
if shadow_length <= 0 or steps < 1:
|
|
return torch.zeros_like(fg_mask)
|
|
|
|
# Trace small and upsample; the caller blurs the result anyway.
|
|
scale = min(1.0, _SHADOW_TRACE_MAX / max(height, width))
|
|
trace_h = max(8, int(round(height * scale)))
|
|
trace_w = max(8, int(round(width * scale)))
|
|
source = fg_mask.unsqueeze(1)
|
|
if (trace_h, trace_w) != (height, width):
|
|
source = F.interpolate(source, size=(trace_h, trace_w), mode="bilinear", align_corners=False)
|
|
|
|
rows = torch.arange(trace_h, device=device, dtype=torch.float32).view(trace_h, 1)
|
|
cols = torch.arange(trace_w, device=device, dtype=torch.float32).view(1, trace_w)
|
|
light_x = light_position_x * trace_w
|
|
light_y = light_position_y * trace_h
|
|
to_light_x = light_x - cols
|
|
to_light_y = light_y - rows
|
|
distance = torch.sqrt(to_light_x * to_light_x + to_light_y * to_light_y).clamp(min=1e-6)
|
|
step_x = to_light_x / distance
|
|
step_y = to_light_y / distance
|
|
reach = shadow_length * min(trace_w, trace_h)
|
|
|
|
shadow = torch.zeros((batch, trace_h, trace_w), device=device, dtype=torch.float32)
|
|
denom_x = max(trace_w - 1, 1)
|
|
denom_y = max(trace_h - 1, 1)
|
|
for step in range(1, steps + 1):
|
|
travelled = torch.clamp(distance, max=reach * step / steps)
|
|
sample_x = cols + step_x * travelled
|
|
sample_y = rows + step_y * travelled
|
|
grid = torch.stack(
|
|
[
|
|
(sample_x / denom_x * 2.0 - 1.0).expand(trace_h, trace_w),
|
|
(sample_y / denom_y * 2.0 - 1.0).expand(trace_h, trace_w),
|
|
],
|
|
dim=-1,
|
|
).unsqueeze(0).expand(batch, -1, -1, -1)
|
|
sampled = F.grid_sample(
|
|
source, grid, mode="bilinear", padding_mode="zeros", align_corners=True
|
|
)
|
|
shadow = torch.maximum(shadow, sampled.squeeze(1))
|
|
|
|
if (trace_h, trace_w) != (height, width):
|
|
shadow = F.interpolate(
|
|
shadow.unsqueeze(1), size=(height, width), mode="bilinear", align_corners=False
|
|
).squeeze(1)
|
|
return shadow.clamp(0.0, 1.0)
|
|
|
|
@classmethod
|
|
def calculate_rim_mask(cls, light_mask_np, fg_mask_np, light_position_x, light_position_y):
|
|
"""
|
|
Calculates the raw (unblurred, unamplified) rim mask.
|
|
|
|
This mask represents the light hitting the edges of the foreground subject,
|
|
modulated by the direction of the light source relative to the edge normal.
|
|
Requires SciPy for edge detection (ndimage.sobel).
|
|
|
|
Args:
|
|
light_mask_np (np.ndarray): The base light mask (e.g., outer circle)
|
|
as a NumPy array, defining the potential
|
|
area and intensity of the light source.
|
|
fg_mask_np (np.ndarray): The foreground subject mask (1=subject, 0=background)
|
|
as a NumPy array.
|
|
light_position_x (float): Normalized horizontal light position (0-1).
|
|
light_position_y (float): Normalized vertical light position (0-1).
|
|
|
|
Returns:
|
|
np.ndarray: The calculated raw rim mask as a NumPy array (float32, range 0-1).
|
|
Returns an empty (all zeros) mask if no edges are detected.
|
|
"""
|
|
height, width = fg_mask_np.shape
|
|
|
|
# 1. Edge Detection using Sobel filter
|
|
edge_x = ndimage.sobel(fg_mask_np, axis=1)
|
|
edge_y = ndimage.sobel(fg_mask_np, axis=0)
|
|
grad_magnitude = np.sqrt(edge_x**2 + edge_y**2)
|
|
|
|
max_edge = grad_magnitude.max()
|
|
if max_edge > 1e-6:
|
|
# Normalize to 0-1, then enhance edges slightly with a power function
|
|
# (value < 1 thickens/brightens). The raw magnitude is reused below
|
|
# for the edge normals, so it is computed once and never overwritten.
|
|
edge_mask = np.power(grad_magnitude / max_edge, 0.7)
|
|
else:
|
|
# A uniform mask (all subject or all background) has no edge to rim.
|
|
logger.debug("No edges detected for rim mask.")
|
|
return np.zeros_like(light_mask_np)
|
|
|
|
# 2. Base Rim Light: Modulate edge mask by the original light intensity
|
|
# This ensures rim light only appears where the original light would hit the edge
|
|
rim_light_raw = light_mask_np * edge_mask
|
|
|
|
# 3. Directional Modulation: Make rim brighter where light hits edge from behind
|
|
y_grid, x_grid = cls._pixel_grid(width, height)
|
|
light_x_px, light_y_px = light_position_x * width, light_position_y * height
|
|
light_dir_x, light_dir_y = x_grid - light_x_px, y_grid - light_y_px
|
|
light_dist = np.sqrt(light_dir_x**2 + light_dir_y**2)
|
|
light_dist = np.where(light_dist < 1e-6, 1, light_dist)
|
|
light_dir_x = light_dir_x / light_dist
|
|
light_dir_y = light_dir_y / light_dist
|
|
grad_magnitude_norm = np.where(grad_magnitude < 1e-6, 1, grad_magnitude)
|
|
normal_x, normal_y = edge_x / grad_magnitude_norm, edge_y / grad_magnitude_norm
|
|
dot_product = light_dir_x * normal_x + light_dir_y * normal_y
|
|
directional_factor = np.clip((-dot_product + 1) / 2, 0, 1)
|
|
directional_factor = np.power(directional_factor, 1.5)
|
|
final_mask_np = np.clip(rim_light_raw * directional_factor, 0.0, 1.0)
|
|
|
|
logger.debug(f" - Raw rim mask calculated: Max intensity = {final_mask_np.max():.3f}")
|
|
return final_mask_np.astype(np.float32)
|
|
|
|
@classmethod
|
|
def create_debug_image(cls, original_image, all_inner_base_masks, all_outer_base_masks, light_sources, fg_mask=None):
|
|
"""Create a debug visualization showing base masks and light positions."""
|
|
logger.debug("--- Creating Debug Image ---")
|
|
try:
|
|
img_tensor = original_image[0].cpu()
|
|
fg_mask_tensor = fg_mask[0].cpu() if fg_mask is not None else None
|
|
# Use base masks for the *first* light source for visualization
|
|
inner_base_mask = all_inner_base_masks[0].cpu() if all_inner_base_masks else None
|
|
outer_base_mask = all_outer_base_masks[0].cpu() if all_outer_base_masks else None
|
|
|
|
img_np = (img_tensor.clamp(0, 1).numpy() * 255).astype(np.uint8)
|
|
if img_np.shape[-1] == 1:
|
|
# The debug view is always RGB; PIL cannot build an image from an
|
|
# (H, W, 1) array, and a grayscale input used to land in the
|
|
# fatal-error path and come back solid black.
|
|
img_np = np.repeat(img_np, 3, axis=-1)
|
|
pil_img = Image.fromarray(img_np).convert('RGBA')
|
|
width, height = pil_img.size
|
|
logger.debug(f" Base image size: {width}x{height}")
|
|
|
|
# --- Create Overlays ---
|
|
inner_overlay_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
ring_overlay_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
fg_overlay_img = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
|
|
|
# Inner Mask Overlay (Red)
|
|
try:
|
|
if inner_base_mask is not None:
|
|
inner_alpha = (np.clip(inner_base_mask.numpy(), 0, 1) * 128).astype(np.uint8)
|
|
if np.max(inner_alpha) > 0:
|
|
inner_overlay_np = np.zeros((height, width, 4), dtype=np.uint8)
|
|
inner_overlay_np[..., 0] = 255 # R
|
|
inner_overlay_np[..., 3] = inner_alpha
|
|
inner_overlay_img = Image.fromarray(inner_overlay_np, 'RGBA')
|
|
except Exception:
|
|
logger.exception(" ERROR creating inner mask overlay")
|
|
|
|
# Ring Mask Overlay (Blue)
|
|
try:
|
|
if inner_base_mask is not None and outer_base_mask is not None:
|
|
ring_np = np.clip(outer_base_mask.numpy() - inner_base_mask.numpy(), 0, 1)
|
|
ring_alpha = (ring_np * 128).astype(np.uint8)
|
|
if np.max(ring_alpha) > 0:
|
|
ring_overlay_np = np.zeros((height, width, 4), dtype=np.uint8)
|
|
ring_overlay_np[..., 2] = 255 # B
|
|
ring_overlay_np[..., 3] = ring_alpha
|
|
ring_overlay_img = Image.fromarray(ring_overlay_np, 'RGBA')
|
|
except Exception:
|
|
logger.exception(" ERROR creating ring mask overlay")
|
|
|
|
# Foreground Mask Overlay (Green)
|
|
try:
|
|
if fg_mask_tensor is not None:
|
|
fg_alpha = (np.clip(fg_mask_tensor.numpy(), 0, 1) * 100).astype(np.uint8)
|
|
if np.max(fg_alpha) > 0:
|
|
fg_overlay_np = np.zeros((height, width, 4), dtype=np.uint8)
|
|
fg_overlay_np[..., 1] = 255
|
|
fg_overlay_np[..., 3] = fg_alpha
|
|
fg_overlay_img = Image.fromarray(fg_overlay_np, 'RGBA')
|
|
except Exception:
|
|
logger.exception(" ERROR creating FG mask overlay")
|
|
|
|
# --- Composite Overlays ---
|
|
debug_img = pil_img
|
|
try:
|
|
# Composite order: Base -> FG (Green) -> Inner (Red) -> Ring (Blue)
|
|
debug_img = Image.alpha_composite(debug_img, fg_overlay_img)
|
|
debug_img = Image.alpha_composite(debug_img, inner_overlay_img)
|
|
debug_img = Image.alpha_composite(debug_img, ring_overlay_img)
|
|
except Exception:
|
|
logger.exception(" ERROR compositing overlays")
|
|
|
|
# --- Draw Indicators & Legend ---
|
|
draw_debug = ImageDraw.Draw(debug_img)
|
|
# Same scaling rule as the placeholder: at 1344x768 the old fixed
|
|
# 12px legend and 5px dots were unreadable in a preview thumbnail.
|
|
font_size = _debug_font_size(height)
|
|
font = _load_debug_font(font_size)
|
|
marker_radius = max(4, font_size // 2)
|
|
ring_width = max(1, font_size // 8)
|
|
for i, light in enumerate(light_sources):
|
|
try:
|
|
x, y = int(light["position_x"] * width), int(light["position_y"] * height)
|
|
color = tuple(light.get("color", [255, 255, 255])) + (220,)
|
|
inner_r_px = int(light["inner_radius"] * min(width, height))
|
|
outer_r_px = int(light["outer_radius"] * min(width, height))
|
|
draw_debug.ellipse((x-marker_radius, y-marker_radius, x+marker_radius, y+marker_radius), fill=color, outline=(0, 0, 0, 200), width=ring_width)
|
|
draw_debug.ellipse((x-inner_r_px, y-inner_r_px, x+inner_r_px, y+inner_r_px), outline=(255, 255, 0, 150), width=ring_width)
|
|
draw_debug.ellipse((x-outer_r_px, y-outer_r_px, x+outer_r_px, y+outer_r_px), outline=(0, 255, 255, 150), width=ring_width)
|
|
label = f"L{i+1}"
|
|
# The label sits to the right of the marker, but a light near
|
|
# the right edge (a rim light at x=0.9, say) would push it off
|
|
# the frame, so it flips to the left side instead. Both axes
|
|
# are then clamped, because a marker in a corner can still run
|
|
# a tall glyph past the top or bottom.
|
|
gap = marker_radius + ring_width * 2
|
|
label_w = draw_debug.textlength(label, font=font) if font else font_size
|
|
text_x = x + gap
|
|
if text_x + label_w > width:
|
|
text_x = x - gap - label_w
|
|
text_x = max(0, min(text_x, width - label_w))
|
|
text_y = max(0, min(y - font_size // 2 - 2, height - font_size))
|
|
text_pos = (text_x, text_y)
|
|
if font:
|
|
bbox = draw_debug.textbbox(text_pos, label, font=font)
|
|
draw_debug.rectangle(bbox, fill=(0, 0, 0, 180))
|
|
draw_debug.text(text_pos, label, fill=(255, 255, 255, 230), font=font)
|
|
else:
|
|
draw_debug.text(text_pos, label, fill=(255, 255, 255, 230))
|
|
except Exception:
|
|
logger.exception(f" ERROR drawing indicator for light {i+1}")
|
|
try:
|
|
legend_items = [("Inner Mask Area", (255, 0, 0, 128)), ("Outer Mask Area (Ring)", (0, 0, 255, 128))]
|
|
if fg_mask_tensor is not None:
|
|
legend_items.append(("Foreground Mask", (0, 255, 0, 100)))
|
|
pad = max(5, font_size // 2)
|
|
swatch = font_size
|
|
legend_x = legend_y = pad * 2
|
|
line_height = int(font_size * 1.5)
|
|
max_width = 0
|
|
for text, _ in legend_items:
|
|
try:
|
|
text_w = draw_debug.textlength(text, font=font) if font else len(text) * font_size * 0.55
|
|
except Exception:
|
|
text_w = len(text) * font_size * 0.55
|
|
max_width = max(max_width, text_w)
|
|
legend_box = (
|
|
legend_x - pad,
|
|
legend_y - pad,
|
|
legend_x + swatch + pad + max_width + pad,
|
|
legend_y + len(legend_items) * line_height,
|
|
)
|
|
draw_debug.rectangle(legend_box, fill=(0, 0, 0, 190))
|
|
for text, color in legend_items:
|
|
draw_debug.rectangle((legend_x, legend_y, legend_x + swatch, legend_y + swatch), fill=color)
|
|
draw_debug.text((legend_x + swatch + pad, legend_y), text, fill=(255, 255, 255, 220), font=font)
|
|
legend_y += line_height
|
|
except Exception:
|
|
logger.exception(" ERROR drawing legend")
|
|
|
|
debug_np = np.array(debug_img.convert('RGB')).astype(np.float32) / 255.0
|
|
logger.debug("--- Debug Image Creation Finished ---")
|
|
return torch.from_numpy(debug_np).unsqueeze(0).to(original_image.device)
|
|
|
|
except Exception:
|
|
logger.exception("--- FATAL ERROR in create_debug_image ---")
|
|
return cls._blank_debug_image(
|
|
original_image, "The debug view could not be drawn - see the ComfyUI console."
|
|
)
|
|
|
|
@staticmethod
|
|
def _blank_debug_image(image, reason=None):
|
|
"""RGB placeholder matching the image's spatial size.
|
|
|
|
A solid black frame is indistinguishable from a crash: users wire the
|
|
debug output to a preview, see black, and reasonably conclude the node
|
|
failed. So the placeholder states why it is empty and how to fill it.
|
|
|
|
Everything here scales with the frame. v3.1.2 drew 13px type on a
|
|
full-resolution canvas, which is legible at 96x64 and invisible at 1344
|
|
wide, so the placeholder was reported as the very black frame it was
|
|
written to replace. The inset border is the other half of that: it means
|
|
even an unreadable thumbnail is visibly a deliberate panel rather than a
|
|
dead output. Falls back to plain black only if the text cannot be drawn
|
|
(no font, or an image too small to hold a line of type).
|
|
"""
|
|
height, width = image.shape[1], image.shape[2]
|
|
blank = torch.zeros((1, height, width, 3), device=image.device, dtype=torch.float32)
|
|
if not reason or width < 64 or height < 24:
|
|
return blank
|
|
try:
|
|
canvas = Image.new("RGB", (width, height), (24, 24, 28))
|
|
draw = ImageDraw.Draw(canvas)
|
|
font_size = _debug_font_size(height)
|
|
font = _load_debug_font(font_size)
|
|
|
|
border = max(2, font_size // 6)
|
|
inset = max(4, font_size // 2)
|
|
draw.rectangle(
|
|
(inset, inset, width - 1 - inset, height - 1 - inset),
|
|
outline=(96, 96, 112),
|
|
width=border,
|
|
)
|
|
|
|
margin = inset + border + font_size
|
|
lines = _wrap_debug_text(draw, reason, font, max(font_size, width - 2 * margin))
|
|
line_height = int(font_size * 1.4)
|
|
y = max(margin, (height - line_height * len(lines)) // 2)
|
|
for line in lines:
|
|
try:
|
|
text_width = draw.textlength(line, font=font)
|
|
except Exception:
|
|
text_width = len(line) * font_size * 0.55
|
|
draw.text(
|
|
(max(margin, int((width - text_width) // 2)), y),
|
|
line,
|
|
fill=(198, 198, 210),
|
|
font=font,
|
|
)
|
|
y += line_height
|
|
placeholder = np.array(canvas).astype(np.float32) / 255.0
|
|
return torch.from_numpy(placeholder).unsqueeze(0).to(image.device)
|
|
except Exception:
|
|
logger.debug("Could not render the debug placeholder text; using a black frame.")
|
|
return blank
|
|
|
|
#: Output slot the debug view is on. A downstream node consuming it appears
|
|
#: in the submitted prompt as the pair ``[<this node's id>, 2]``.
|
|
DEBUG_OUTPUT_SLOT = 2
|
|
|
|
@classmethod
|
|
def _debug_output_is_consumed(cls):
|
|
"""Does the submitted prompt wire anything to this node's debug output?
|
|
|
|
The node's own `debug_output_connected` widget is what makes the UI feel
|
|
immediate (see web/relight_debug.js - a widget value is what ComfyUI
|
|
hashes to decide a node must re-run). This is the independent check for
|
|
everything that never loads that file: an API caller posting to /prompt,
|
|
or a UI where the frontend extension failed to load. Either signal is
|
|
enough; neither is required.
|
|
|
|
Deliberately total: hidden inputs are absent outside a running ComfyUI
|
|
and empty in some internal calls, so every failure here means "no", not
|
|
an exception on a path the user never asked about.
|
|
"""
|
|
hidden = getattr(cls, "hidden", None)
|
|
prompt = getattr(hidden, "prompt", None)
|
|
unique_id = getattr(hidden, "unique_id", None)
|
|
if not prompt or unique_id is None:
|
|
return False
|
|
wanted = str(unique_id)
|
|
try:
|
|
for node in prompt.values():
|
|
for value in (node.get("inputs") or {}).values():
|
|
if (
|
|
isinstance(value, (list, tuple))
|
|
and len(value) == 2
|
|
and str(value[0]) == wanted
|
|
and value[1] == cls.DEBUG_OUTPUT_SLOT
|
|
):
|
|
return True
|
|
except (AttributeError, TypeError):
|
|
# A prompt shape we do not recognise is not worth crashing a render.
|
|
logger.debug("ReLight: could not read the prompt to check debug connectivity.")
|
|
return False
|
|
|
|
# --- Mask preparation ---
|
|
|
|
@classmethod
|
|
def _prepare_mask(cls, input_mask, batch_size, height, width, device):
|
|
"""Normalise an incoming MASK to a clean (batch, height, width) float tensor.
|
|
|
|
Handles rank (2D/3D/4D and RGB/RGBA sources), resolution (a mask made
|
|
before an upscale no longer crashes the node), value range and batch size.
|
|
"""
|
|
fg_mask = input_mask.to(device=device, dtype=torch.float32)
|
|
|
|
if fg_mask.dim() == 4:
|
|
if fg_mask.shape[3] == 1:
|
|
fg_mask = fg_mask.squeeze(-1)
|
|
elif fg_mask.shape[3] == 4:
|
|
fg_mask = fg_mask[..., 3]
|
|
elif fg_mask.shape[3] == 3:
|
|
fg_mask = fg_mask.mean(dim=-1)
|
|
else:
|
|
fg_mask = fg_mask[..., 0]
|
|
elif fg_mask.dim() == 2:
|
|
fg_mask = fg_mask.unsqueeze(0)
|
|
elif fg_mask.dim() != 3:
|
|
raise ValueError(f"ReLight: cannot interpret a mask with shape {tuple(input_mask.shape)}")
|
|
|
|
if fg_mask.shape[-2:] != (height, width):
|
|
logger.info(
|
|
f"ReLight: resizing mask from {tuple(fg_mask.shape[-2:])} to {(height, width)} to match the image."
|
|
)
|
|
fg_mask = F.interpolate(
|
|
fg_mask.unsqueeze(1), size=(height, width), mode="bilinear", align_corners=False
|
|
).squeeze(1)
|
|
|
|
fg_mask = fg_mask.clamp(0.0, 1.0)
|
|
|
|
if fg_mask.shape[0] != batch_size:
|
|
if fg_mask.shape[0] != 1:
|
|
logger.warning(
|
|
f"ReLight: mask batch ({fg_mask.shape[0]}) does not match image batch ({batch_size}); "
|
|
"using the first mask for every image."
|
|
)
|
|
fg_mask = fg_mask[0:1].expand(batch_size, -1, -1).contiguous()
|
|
|
|
mean_val = fg_mask.mean().item()
|
|
if mean_val > 0.9:
|
|
logger.warning(
|
|
f"ReLight: mask is {mean_val * 100:.0f}% white. If your subject appears unlit, the mask may be "
|
|
"inverted (expected: white=subject, black=background)."
|
|
)
|
|
return fg_mask
|
|
|
|
# --- Main Execution Function ---
|
|
|
|
@classmethod
|
|
def execute(cls, image: torch.Tensor, **kwargs) -> io.NodeOutput:
|
|
"""Applies relighting effects to the input image based on parameters."""
|
|
start_time = time.time()
|
|
image = image.to(dtype=torch.float32)
|
|
if image.dim() != 4:
|
|
raise ValueError(f"ReLight: expected an image of shape (batch, height, width, channels), got {tuple(image.shape)}")
|
|
batch_size, height, width, channels = image.shape
|
|
device = image.device
|
|
|
|
# Keep any alpha channel out of the lighting maths and re-attach it at the
|
|
# end; the correction path is RGB-only and used to crash on RGBA inputs.
|
|
alpha = None
|
|
if channels == 4:
|
|
alpha = image[..., 3:]
|
|
image = image[..., :3]
|
|
elif channels not in (1, 3):
|
|
raise ValueError(f"ReLight: expected a 1-, 3- or 4-channel image, got {channels} channels")
|
|
|
|
logger.debug("\n--- ReLight Node ---")
|
|
logger.debug(f"Input Image: {width}x{height}, Batch: {batch_size}, Device: {device}, Shape: {image.shape}")
|
|
|
|
params = kwargs.copy()
|
|
params = cls._load_preset(params.get('preset', 'None'), params)
|
|
|
|
preset = params.get('preset', 'None')
|
|
remove_background = params.get('remove_background', False)
|
|
subject_interaction = params.get('subject_interaction', cls.SUBJECT_NONE)
|
|
lighting_mode = params.get('lighting_mode', cls.MODE_CORRECTION)
|
|
mask_shape = params.get('mask_shape', cls.SHAPE_RADIAL)
|
|
debug_output_connected = bool(params.get('debug_output_connected', False)) or cls._debug_output_is_consumed()
|
|
effect_strength = params.get('effect_strength', 1.0)
|
|
rim_amplification = params.get('rim_amplification', 2.0)
|
|
shadow_strength = params.get('shadow_strength', 0.6)
|
|
shadow_length = params.get('shadow_length', 0.35)
|
|
num_light_sources = params.get('num_light_sources', 1)
|
|
mask_blur = params.get('mask_blur', 50.0)
|
|
input_mask = params.get('mask', None)
|
|
|
|
# The three lighting modes are two independent switches underneath: a
|
|
# coloured additive pass and a two-zone grade. 'Both' runs the colour
|
|
# first and grades the result, which is what the three presets that set
|
|
# a colour *and* a full correction block always looked like they meant.
|
|
apply_colored = lighting_mode in (cls.MODE_COLORED, cls.MODE_BOTH)
|
|
apply_correction = lighting_mode in (cls.MODE_CORRECTION, cls.MODE_BOTH)
|
|
use_gradient_mode = mask_shape == cls.SHAPE_GRADIENT
|
|
|
|
logger.debug(f"Mode: Preset='{preset}', Lighting='{lighting_mode}', Shape='{mask_shape}', Subject='{subject_interaction}'")
|
|
logger.debug(f"Settings: Strength={effect_strength:.2f}, Rim Amp={rim_amplification:.2f}, Mask Blur={mask_blur:.1f}, Debug={debug_output_connected}")
|
|
|
|
# --- Mask Handling ---
|
|
fg_mask = None
|
|
if input_mask is not None:
|
|
fg_mask = cls._prepare_mask(input_mask, batch_size, height, width, device)
|
|
# The MASK output is always the normalised mask, regardless of which
|
|
# features happen to be enabled.
|
|
output_mask = fg_mask.clone()
|
|
else:
|
|
output_mask = torch.zeros((batch_size, height, width), device=device, dtype=torch.float32)
|
|
|
|
occlusion_requested = subject_interaction != cls.SUBJECT_NONE
|
|
if fg_mask is None and (occlusion_requested or remove_background):
|
|
logger.info("ReLight: no mask connected - occlusion and background compositing are disabled for this run.")
|
|
occlusion_active = occlusion_requested and fg_mask is not None
|
|
composite_active = remove_background and fg_mask is not None
|
|
|
|
# --- Define Light Sources ---
|
|
light_sources = []
|
|
for i in range(1, num_light_sources + 1):
|
|
prefix = f"light{i}_" if i > 1 else "light_"
|
|
pos_x_key, pos_y_key = ("light_position_x", "light_position_y") if i == 1 else (f"light{i}_position_x", f"light{i}_position_y")
|
|
inner_r_key, outer_r_key = ("inner_circle_radius", "outer_circle_radius") if i == 1 else (f"light{i}_inner_radius", f"light{i}_outer_radius")
|
|
color_r_key, color_g_key, color_b_key = f"{prefix}color_r", f"{prefix}color_g", f"{prefix}color_b"
|
|
intensity_key = f"{prefix}intensity"
|
|
if params.get(pos_x_key) is None or params.get(pos_y_key) is None:
|
|
if i > 1:
|
|
continue
|
|
raise ValueError("Missing essential parameters for Light 1")
|
|
light = {
|
|
"id": i,
|
|
"position_x": params.get(pos_x_key, 0.5), "position_y": params.get(pos_y_key, 0.5),
|
|
"inner_radius": params.get(inner_r_key, 0.3), "outer_radius": params.get(outer_r_key, 0.6),
|
|
"color": [int(params.get(color_r_key, 255)), int(params.get(color_g_key, 255)), int(params.get(color_b_key, 255))],
|
|
"intensity": params.get(intensity_key, 1.0),
|
|
}
|
|
light_sources.append(light)
|
|
logger.debug(f"Defined Light {i}: Pos=({light['position_x']:.2f},{light['position_y']:.2f}), Radii=({light['inner_radius']:.2f},{light['outer_radius']:.2f}), Color={light['color']}, Intensity={light['intensity']:.2f}")
|
|
|
|
# --- Initialize Result & Masks for Debug ---
|
|
result_tensor = image.clone()
|
|
all_inner_base_masks_for_debug = []
|
|
all_outer_base_masks_for_debug = []
|
|
|
|
# --- Process Each Light Source ---
|
|
for light in light_sources:
|
|
logger.debug(f"\nProcessing Light Source {light['id']}...")
|
|
|
|
# --- Create Base Light Masks (position-only, shared across the batch) ---
|
|
if use_gradient_mode:
|
|
dx, dy = light["position_x"] - 0.5, light["position_y"] - 0.5
|
|
angle = math.degrees(math.atan2(dy, dx))
|
|
inner_mask_base = cls.create_gradient_mask(width, height, light["position_x"], light["position_y"], light["inner_radius"], angle).to(device)
|
|
outer_mask_base = cls.create_gradient_mask(width, height, light["position_x"], light["position_y"], light["outer_radius"], angle).to(device)
|
|
else:
|
|
inner_mask_base = cls.create_circle_mask(width, height, light["position_x"], light["position_y"], light["inner_radius"]).to(device)
|
|
outer_mask_base = cls.create_circle_mask(width, height, light["position_x"], light["position_y"], light["outer_radius"]).to(device)
|
|
|
|
all_inner_base_masks_for_debug.append(inner_mask_base)
|
|
all_outer_base_masks_for_debug.append(outer_mask_base)
|
|
|
|
# --- MASK APPLICATION LOGIC ---
|
|
# Light masks carry a leading dimension that is either 1 (identical for
|
|
# every image) or batch_size (occlusion, which depends on each image's
|
|
# own mask). Both broadcast against the (B, H, W, C) image.
|
|
inner_zone_mask = None
|
|
ring_zone_mask = None
|
|
colored_light_mask = None
|
|
correction_mask = None
|
|
|
|
if occlusion_active and subject_interaction == cls.SUBJECT_RIM:
|
|
logger.debug(" Processing as Behind Subject (Rim + Background Glow)...")
|
|
light_mask_np = outer_mask_base.cpu().numpy()
|
|
|
|
rim_per_image = []
|
|
for b in range(batch_size):
|
|
raw_rim_mask_np = cls.calculate_rim_mask(
|
|
light_mask_np, fg_mask[b].cpu().numpy(), light["position_x"], light["position_y"]
|
|
)
|
|
rim_per_image.append(np.clip(raw_rim_mask_np * rim_amplification, 0.0, 1.0))
|
|
amplified_rim = torch.from_numpy(np.stack(rim_per_image)).to(device)
|
|
|
|
# The background glow gets real falloff. Up to v3.1.2 it was the
|
|
# hard 0/1 outer disc, so "glow" was a flat slab with whatever
|
|
# softness mask_blur happened to put on its rim.
|
|
if use_gradient_mode:
|
|
background_base = outer_mask_base
|
|
else:
|
|
background_base = cls.create_falloff_mask(
|
|
width, height, light["position_x"], light["position_y"],
|
|
light["inner_radius"], light["outer_radius"],
|
|
).to(device)
|
|
|
|
background_light_mask = background_base.unsqueeze(0).expand(batch_size, -1, -1)
|
|
if shadow_strength > 0.0 and shadow_length > 0.0:
|
|
shadow = cls.cast_shadow_mask(
|
|
fg_mask, light["position_x"], light["position_y"], shadow_length
|
|
)
|
|
background_light_mask = background_light_mask * (1.0 - shadow * shadow_strength)
|
|
|
|
# Blur the two halves SEPARATELY, and re-apply the silhouette to
|
|
# the blurred background. v3.1.2 subtracted the subject before
|
|
# the blur, so the blur smeared background light straight back
|
|
# across the silhouette edge onto the face - the single most
|
|
# direct cause of "Behind Subject does not occlude".
|
|
blurred_rim = cls.apply_mask_blur(amplified_rim, mask_blur)
|
|
blurred_background = cls.apply_mask_blur(background_light_mask, mask_blur) * (1.0 - fg_mask)
|
|
final_light_mask = torch.clamp(blurred_rim + blurred_background, 0, 1)
|
|
colored_light_mask = correction_mask = final_light_mask
|
|
logger.debug(f" Final Behind Subject Mask: Max={torch.max(final_light_mask):.3f}")
|
|
|
|
elif occlusion_active and subject_interaction == cls.SUBJECT_FRONT:
|
|
logger.debug(" Processing as Front Subject Light...")
|
|
light_mask_np = outer_mask_base.cpu().numpy()
|
|
fg_mask_np = fg_mask.cpu().numpy()
|
|
|
|
enhance_subject_factor = 1.3
|
|
reduce_background_factor = 0.8
|
|
occlusion_factor_mask_np = fg_mask_np * enhance_subject_factor + (1.0 - fg_mask_np) * reduce_background_factor
|
|
occlusion_factor_mask_np = ndimage.gaussian_filter(occlusion_factor_mask_np, sigma=(0, 2, 2))
|
|
|
|
combined_mask_unblurred = torch.from_numpy(
|
|
np.clip(light_mask_np[None, ...] * occlusion_factor_mask_np, 0, 1).astype(np.float32)
|
|
).to(device)
|
|
final_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur)
|
|
colored_light_mask = correction_mask = final_light_mask
|
|
logger.debug(f" Final Front Subject Mask: Max={torch.max(final_light_mask):.3f}")
|
|
|
|
else: # Standard (no subject interaction, or no mask to do it with)
|
|
logger.debug(" Processing as Standard Light...")
|
|
if apply_colored:
|
|
# Light area with real falloff: full strength inside the inner
|
|
# radius, fading to zero at the outer radius. Gradient masks
|
|
# already encode their own falloff.
|
|
if use_gradient_mode:
|
|
combined_mask_unblurred = outer_mask_base
|
|
else:
|
|
combined_mask_unblurred = cls.create_falloff_mask(
|
|
width, height, light["position_x"], light["position_y"],
|
|
light["inner_radius"], light["outer_radius"],
|
|
).to(device)
|
|
colored_light_mask = cls.apply_mask_blur(combined_mask_unblurred, mask_blur).unsqueeze(0)
|
|
if apply_correction:
|
|
# Two-zone color correction: inner_* params apply inside the
|
|
# inner mask, outer_* params in the ring between inner and outer.
|
|
# Both zones blur in one convolution - this is the hot path.
|
|
zones = cls.apply_mask_blur(
|
|
torch.stack([inner_mask_base, outer_mask_base]), mask_blur
|
|
)
|
|
inner_zone_mask, outer_zone_full = zones[0:1], zones[1:2]
|
|
ring_zone_mask = torch.clamp(outer_zone_full - inner_zone_mask, 0, 1)
|
|
correction_mask = torch.clamp(inner_zone_mask + ring_zone_mask, 0, 1)
|
|
|
|
# --- Apply the lighting effect(s) this mode calls for ---
|
|
# In 'Both' the colour goes on first and the grade is applied to the
|
|
# result, so the grade sees the lit image rather than the original.
|
|
if apply_colored and colored_light_mask is not None and torch.max(colored_light_mask) > 1e-4:
|
|
logger.debug(f" Applying colored light (RGB: {light['color']}, Intensity: {light['intensity']:.2f})...")
|
|
effective_intensity = light['intensity'] * effect_strength
|
|
result_tensor = cls.apply_colored_light(result_tensor, colored_light_mask.unsqueeze(-1), light['color'], effective_intensity)
|
|
|
|
if apply_correction and correction_mask is not None and torch.max(correction_mask) > 1e-4:
|
|
logger.debug(" Applying color correction...")
|
|
corrected_inner = cls.apply_color_correction(
|
|
result_tensor,
|
|
params.get('inner_brightness', 0) * effect_strength,
|
|
params.get('inner_contrast', 0) * effect_strength,
|
|
params.get('inner_saturation', 0) * effect_strength,
|
|
params.get('inner_temperature', 0) * effect_strength,
|
|
params.get('inner_tint', 0) * effect_strength,
|
|
cls._scaled_gamma(params.get('inner_gamma', 1.0), effect_strength),
|
|
)
|
|
if inner_zone_mask is not None and ring_zone_mask is not None:
|
|
# Standard mode: two-zone correction. inner_* params in the
|
|
# inner area, outer_* params in the surrounding ring.
|
|
corrected_outer = cls.apply_color_correction(
|
|
result_tensor,
|
|
params.get('outer_brightness', 0) * effect_strength,
|
|
params.get('outer_contrast', 0) * effect_strength,
|
|
params.get('outer_saturation', 0) * effect_strength,
|
|
params.get('outer_temperature', 0) * effect_strength,
|
|
params.get('outer_tint', 0) * effect_strength,
|
|
cls._scaled_gamma(params.get('outer_gamma', 1.0), effect_strength),
|
|
)
|
|
# Blending as base + weighted *deltas* rather than
|
|
# base*(1-mask) + corrected*mask is algebraically identical but
|
|
# exact: where a correction is a no-op its delta is exactly
|
|
# zero, so identity settings leave the image untouched instead
|
|
# of accumulating rounding error across lights and chained nodes.
|
|
inner_expanded = inner_zone_mask.unsqueeze(-1)
|
|
ring_expanded = ring_zone_mask.unsqueeze(-1)
|
|
result_tensor = (
|
|
result_tensor
|
|
+ (corrected_inner - result_tensor) * inner_expanded
|
|
+ (corrected_outer - result_tensor) * ring_expanded
|
|
)
|
|
else:
|
|
# Behind/Front modes: single combined mask with inner params
|
|
# (documented simplification).
|
|
result_tensor = result_tensor + (corrected_inner - result_tensor) * correction_mask.unsqueeze(-1)
|
|
|
|
# --- Final Steps ---
|
|
final_result = result_tensor
|
|
|
|
if composite_active:
|
|
# Gate on whether occlusion actually ran, not on the widget alone:
|
|
# with no mask connected the lighting is plain, so it still needs
|
|
# compositing even if subject_interaction asks for occlusion.
|
|
if not occlusion_active:
|
|
logger.debug("Compositing lit foreground onto original background...")
|
|
final_result = image + (result_tensor - image) * fg_mask.unsqueeze(-1)
|
|
else:
|
|
logger.debug(f"Skipping final compositing for '{subject_interaction}' (lighting applied to FG/BG directly).")
|
|
else:
|
|
logger.debug("Skipping final compositing (remove_background=False or no mask).")
|
|
|
|
# Debug Image Generation. There is no toggle: the debug view is drawn
|
|
# whenever something is consuming this output, and skipped otherwise.
|
|
if not debug_output_connected:
|
|
debug_image = cls._blank_debug_image(
|
|
image,
|
|
"Nothing is connected to ReLight's debug_image output, so there was nothing to draw. "
|
|
"Wire it to a preview to see light positions and mask zones.",
|
|
)
|
|
elif all_inner_base_masks_for_debug and all_outer_base_masks_for_debug:
|
|
debug_image = cls.create_debug_image(
|
|
image,
|
|
all_inner_base_masks_for_debug,
|
|
all_outer_base_masks_for_debug,
|
|
light_sources,
|
|
fg_mask,
|
|
)
|
|
else:
|
|
logger.info("ReLight: no light masks were generated, so the debug view has nothing to draw.")
|
|
debug_image = cls._blank_debug_image(
|
|
image, "No light masks were generated - check the radius and position settings."
|
|
)
|
|
|
|
if not isinstance(debug_image, torch.Tensor) or debug_image.shape[1:] != (height, width, 3):
|
|
logger.warning("ReLight: debug image was unusable; returning a placeholder.")
|
|
debug_image = cls._blank_debug_image(image, "The debug view could not be drawn - see the ComfyUI console.")
|
|
elif debug_image.shape[0] != 1:
|
|
debug_image = debug_image[0:1]
|
|
|
|
if alpha is not None:
|
|
final_result = torch.cat([final_result, alpha], dim=-1)
|
|
|
|
logger.debug(f"--- ReLight processing finished in {time.time() - start_time:.3f} seconds ---")
|
|
return io.NodeOutput(final_result, output_mask, debug_image)
|
|
|
|
@classmethod
|
|
def _scaled_gamma(cls, gamma, effect_strength):
|
|
"""Fade gamma toward identity with effect_strength.
|
|
|
|
Scaled in exponent space rather than linearly. Both forms agree exactly
|
|
at strength 0 (identity) and 1 (the value as dialled in), but linear
|
|
interpolation ran a dimming gamma straight through zero into negative
|
|
territory above strength ~4 - where the safety clamp in
|
|
apply_color_correction turned it into an exponent of 100 and crushed the
|
|
zone to solid black. Clamped to the widget's own range so the master
|
|
strength can never push gamma somewhere the user could not set by hand.
|
|
"""
|
|
if gamma <= 0.0:
|
|
return 1.0
|
|
return min(max(gamma ** effect_strength, GAMMA_MIN), GAMMA_MAX)
|
|
|
|
|
|
# --- v3 Extension Registration ---
|
|
class ReLightExtension(ComfyExtension):
|
|
"""ComfyUI Extension for ReLight node."""
|
|
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
"""Return list of nodes provided by this extension."""
|
|
return [ReLight]
|
|
|
|
|
|
async def comfy_entrypoint() -> ReLightExtension:
|
|
"""Entry point for ComfyUI v3."""
|
|
return ReLightExtension()
|