mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-16 06:26:34 +08:00
test(compositor): pin blend behaviour to a golden fixture; fix luminosity (#15373)
Blending exists in two implementations - the numpy compositor and the layerBlend.frag shader that drives the live preview - with nothing holding them together. They have already diverged once (the safeDiv operand), and a divergence only shows up to the user as 'the render does not match the preview'. Adds compositor_blend_golden.json: every mode, at both endpoints, the midpoint and inside each epsilon guard. Any implementation of these 26 modes must reproduce it. compositor_blend_test.py pins the numpy side to it and additionally spells out the boundary rules by hand, so the guards cannot be re-broken by regenerating the fixture. Diffing the shader against the numpy implementation over that grid leaves exactly one mismatch: luminosity. safe_div guards the denominator and returns 0, so a luminosity layer over a black or near-black backdrop disappears. The backdrop has no hue or saturation to preserve there, so the result should be a neutral grey at the layer's luminance - which is also the analytic limit of i * lum(l)/lum(i) as the backdrop approaches black. The matching four-line shader change is proposed on the frontend PR; with both applied all 26 modes agree. Also clamps layer opacity to [0, 1]. The layer state round-trips through the saved workflow and is accepted verbatim on /prompt, so it is untrusted input; the canvas is only clamped once, after the last layer, so an out-of-range coverage multiplier changes the blend of every layer above it. _parse_background already clamps the same field.
This commit is contained in:
@@ -146,8 +146,22 @@ def _blend_color(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
|
||||
|
||||
def _blend_luminosity(i: np.ndarray, l: np.ndarray) -> np.ndarray:
|
||||
ratio = safe_div(luminance(l), luminance(i))
|
||||
return i * ratio[..., None]
|
||||
# Scale the backdrop so it carries the layer's luminance. Where the backdrop
|
||||
# has no luminance to scale there is no hue or saturation to preserve either,
|
||||
# so the result is a neutral grey at the layer's luminance - which is also the
|
||||
# analytic limit of i * lum(l)/lum(i) as a grey backdrop approaches black.
|
||||
# Guarding the numerator here instead (returning black) makes a luminosity
|
||||
# layer disappear over dark backdrops; see tests-unit/comfy_extras_test/
|
||||
# compositor_blend_golden.json.
|
||||
lum_i = luminance(i)
|
||||
lum_l = luminance(l)
|
||||
degenerate = lum_i <= EPSILON
|
||||
ratio = np.where(degenerate, 0.0, lum_l / np.where(degenerate, 1.0, lum_i))
|
||||
return np.where(
|
||||
degenerate[..., None],
|
||||
np.broadcast_to(lum_l[..., None], i.shape),
|
||||
i * ratio[..., None],
|
||||
)
|
||||
|
||||
|
||||
HSL_BLEND = {
|
||||
|
||||
@@ -272,7 +272,12 @@ def _layer_params(entry, natural_w: int, natural_h: int) -> dict:
|
||||
blend = entry.get("blend")
|
||||
return {
|
||||
"visible": bool(entry.get("visible", True)),
|
||||
"opacity": _number(entry, "opacity", 1.0),
|
||||
# The layer state is untrusted input: it round-trips through the saved
|
||||
# workflow and can be posted directly to /prompt. An out-of-range opacity
|
||||
# would otherwise reach blend_composite as a raw coverage multiplier and
|
||||
# produce negative or greater-than-white RGB. _parse_background already
|
||||
# clamps the same field.
|
||||
"opacity": min(max(_number(entry, "opacity", 1.0), 0.0), 1.0),
|
||||
"blend": blend if isinstance(blend, str) else "normal",
|
||||
"x": _number(transform, "x", 0.0),
|
||||
"y": _number(transform, "y", 0.0),
|
||||
|
||||
111
tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py
Normal file
111
tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Regenerate ``compositor_blend_golden.json``.
|
||||
|
||||
The golden file is the *shared contract* for layer blending. Every
|
||||
implementation of these 26 modes must reproduce it within ``tolerance``:
|
||||
|
||||
* ``comfy_extras/compositor_blend.py`` - numpy, server-side compositing
|
||||
* ``layerBlend.frag`` - GLSL, the live preview in the layer editor
|
||||
* any future CPU reference in the frontend
|
||||
|
||||
Run from the repository root::
|
||||
|
||||
python tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py
|
||||
|
||||
and review the diff. A change to this file is a change to user-visible
|
||||
blending behaviour in every implementation, so it should never be
|
||||
regenerated just to make a test pass.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from comfy_extras.compositor_blend import CHANNEL_BLEND, HSL_BLEND, blend_pixel # noqa: E402
|
||||
|
||||
GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json")
|
||||
|
||||
# Scalar grid for the per-channel modes: both endpoints, the midpoint, values
|
||||
# just inside each endpoint, and values inside the 1e-6 epsilon guards.
|
||||
SCALARS = [0.0, 1e-7, 0.001, 0.25, 0.5, 0.75, 0.999, 1.0 - 1e-7, 1.0]
|
||||
|
||||
# Colour pairs for the HSL modes, which read all three channels at once.
|
||||
COLORS = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 0.5],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.2, 0.4, 0.6],
|
||||
[0.9, 0.1, 0.35],
|
||||
[1e-7, 1e-7, 1e-7],
|
||||
[1e-7, 0.0, 0.0],
|
||||
[0.05, 0.05, 0.05],
|
||||
]
|
||||
|
||||
|
||||
def _round(value) -> float:
|
||||
return round(float(value), 7)
|
||||
|
||||
|
||||
def build() -> dict:
|
||||
channel = {}
|
||||
for mode in CHANNEL_BLEND:
|
||||
rows = []
|
||||
for i in SCALARS:
|
||||
for l in SCALARS:
|
||||
out = blend_pixel(mode, np.float32([i] * 3), np.float32([l] * 3))
|
||||
rows.append([_round(i), _round(l), _round(np.asarray(out).reshape(3)[0])])
|
||||
channel[mode] = rows
|
||||
hsl = {}
|
||||
for mode in HSL_BLEND:
|
||||
rows = []
|
||||
for i in COLORS:
|
||||
for l in COLORS:
|
||||
out = blend_pixel(mode, np.float32(i), np.float32(l))
|
||||
rows.append([
|
||||
[_round(v) for v in i],
|
||||
[_round(v) for v in l],
|
||||
[_round(v) for v in np.asarray(out).reshape(3)],
|
||||
])
|
||||
hsl[mode] = rows
|
||||
return {
|
||||
"_comment": (
|
||||
"Golden blend values shared by comfy_extras/compositor_blend.py and "
|
||||
"layerBlend.frag. Inputs are unpremultiplied colours already in the "
|
||||
"blend space; outputs are unclamped (the compositor clamps once, at "
|
||||
"the end). 'channel' rows are [i, l, out] applied per channel; 'hsl' "
|
||||
"rows are [rgb_backdrop, rgb_layer, rgb_out]. Regenerate with "
|
||||
"tests-unit/comfy_extras_test/compositor_blend_fixture_gen.py."
|
||||
),
|
||||
"tolerance": 1e-4,
|
||||
"channel": channel,
|
||||
"hsl": hsl,
|
||||
}
|
||||
|
||||
|
||||
def dumps(data: dict) -> str:
|
||||
"""One row per line, so a behaviour change shows up as a readable diff."""
|
||||
lines = ["{", f' "_comment": {json.dumps(data["_comment"])},', f' "tolerance": {data["tolerance"]},']
|
||||
for section in ("channel", "hsl"):
|
||||
lines.append(f' "{section}": {{')
|
||||
modes = sorted(data[section])
|
||||
for m_index, mode in enumerate(modes):
|
||||
lines.append(f' "{mode}": [')
|
||||
rows = data[section][mode]
|
||||
for r_index, row in enumerate(rows):
|
||||
comma = "" if r_index == len(rows) - 1 else ","
|
||||
lines.append(f" {json.dumps(row)}{comma}")
|
||||
lines.append(" ]" + ("" if m_index == len(modes) - 1 else ","))
|
||||
lines.append(" }" + ("," if section == "channel" else ""))
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with open(GOLDEN_PATH, "w") as handle:
|
||||
handle.write(dumps(build()))
|
||||
sys.stdout.write(f"wrote {GOLDEN_PATH}\n")
|
||||
2242
tests-unit/comfy_extras_test/compositor_blend_golden.json
Normal file
2242
tests-unit/comfy_extras_test/compositor_blend_golden.json
Normal file
File diff suppressed because it is too large
Load Diff
157
tests-unit/comfy_extras_test/compositor_blend_test.py
Normal file
157
tests-unit/comfy_extras_test/compositor_blend_test.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Blend-mode parity tests for the compositor.
|
||||
|
||||
The compositor blends in three places: this numpy module (server-side), the
|
||||
``layerBlend.frag`` GLSL shader (the live preview the user actually sees), and
|
||||
anything the frontend adds later. They have diverged before, silently, and the
|
||||
divergences only show up as "the render does not look like the preview".
|
||||
|
||||
``compositor_blend_golden.json`` is the shared contract. This file pins the
|
||||
numpy implementation to it and additionally spells out, by hand, the boundary
|
||||
rules that the epsilon guards exist to enforce - so a future refactor of
|
||||
``safe_div`` cannot quietly re-introduce the old behaviour by regenerating the
|
||||
fixture.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from comfy_extras.compositor_blend import (
|
||||
CHANNEL_BLEND,
|
||||
HSL_BLEND,
|
||||
EffectiveMode,
|
||||
blend_composite,
|
||||
blend_pixel,
|
||||
resolve_mode,
|
||||
)
|
||||
|
||||
GOLDEN_PATH = os.path.join(os.path.dirname(__file__), "compositor_blend_golden.json")
|
||||
|
||||
with open(GOLDEN_PATH) as _handle:
|
||||
GOLDEN = json.load(_handle)
|
||||
|
||||
TOLERANCE = GOLDEN["tolerance"]
|
||||
|
||||
|
||||
def _blend(mode: str, i, l) -> np.ndarray:
|
||||
return np.asarray(
|
||||
blend_pixel(mode, np.float32(i), np.float32(l)), dtype=np.float64
|
||||
).reshape(3)
|
||||
|
||||
|
||||
def test_golden_covers_every_mode():
|
||||
"""A new blend mode must arrive with golden values, not silently."""
|
||||
assert set(GOLDEN["channel"]) == set(CHANNEL_BLEND)
|
||||
assert set(GOLDEN["hsl"]) == set(HSL_BLEND)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(CHANNEL_BLEND))
|
||||
def test_channel_modes_match_golden(mode):
|
||||
for i, l, expected in GOLDEN["channel"][mode]:
|
||||
actual = _blend(mode, [i] * 3, [l] * 3)
|
||||
assert actual == pytest.approx([expected] * 3, abs=TOLERANCE), (
|
||||
f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(HSL_BLEND))
|
||||
def test_hsl_modes_match_golden(mode):
|
||||
for i, l, expected in GOLDEN["hsl"][mode]:
|
||||
actual = _blend(mode, i, l)
|
||||
assert actual == pytest.approx(expected, abs=TOLERANCE), (
|
||||
f"{mode}(i={i}, l={l}) -> {actual.tolist()}, golden {expected}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", sorted(set(CHANNEL_BLEND) | set(HSL_BLEND)))
|
||||
def test_no_mode_produces_nan_or_inf(mode):
|
||||
edges = [0.0, 1e-7, 1e-6, 0.5, 1.0 - 1e-7, 1.0]
|
||||
for i in edges:
|
||||
for l in edges:
|
||||
out = _blend(mode, [i, 0.0, 1.0], [l, 1.0, 0.0])
|
||||
assert np.all(np.isfinite(out)), f"{mode}(i={i}, l={l}) -> {out.tolist()}"
|
||||
|
||||
|
||||
class TestBoundaryRules:
|
||||
"""The rules the epsilon guards encode, written out independently of the fixture."""
|
||||
|
||||
def test_color_dodge_full_layer_is_white_not_black(self):
|
||||
# Guarding the denominator returns 0 here, which reads as "the dodge
|
||||
# layer turned the image black" - the exact inversion CodeRabbit flagged.
|
||||
assert _blend("color-dodge", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_color_dodge_black_backdrop_stays_black(self):
|
||||
assert _blend("color-dodge", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_color_dodge_is_clamped(self):
|
||||
assert _blend("color-dodge", [0.6] * 3, [0.9] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_color_burn_empty_layer_is_black_not_white(self):
|
||||
assert _blend("color-burn", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_color_burn_white_backdrop_stays_white(self):
|
||||
assert _blend("color-burn", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_vivid_light_boundaries(self):
|
||||
assert _blend("vivid-light", [0.5] * 3, [0.0] * 3) == pytest.approx([0.0] * 3)
|
||||
assert _blend("vivid-light", [0.5] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("vivid-light", [1.0] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("vivid-light", [0.0] * 3, [1.0] * 3) == pytest.approx([0.0] * 3)
|
||||
|
||||
def test_divide_by_zero_is_clamped_to_one(self):
|
||||
assert _blend("divide", [0.5] * 3, [0.0] * 3) == pytest.approx([1.0] * 3)
|
||||
|
||||
def test_luminosity_over_black_takes_the_layer_luminance(self):
|
||||
# A luminosity layer over a black backdrop must not vanish. There is no
|
||||
# hue or saturation in the backdrop to preserve, so the result is a
|
||||
# neutral grey at the layer's luminance.
|
||||
assert _blend("luminosity", [0.0] * 3, [1.0] * 3) == pytest.approx([1.0] * 3)
|
||||
assert _blend("luminosity", [0.0] * 3, [0.5] * 3) == pytest.approx([0.5] * 3)
|
||||
|
||||
def test_luminosity_is_continuous_approaching_black(self):
|
||||
near = _blend("luminosity", [1e-7] * 3, [1.0] * 3)
|
||||
at = _blend("luminosity", [0.0] * 3, [1.0] * 3)
|
||||
assert near == pytest.approx(at, abs=TOLERANCE)
|
||||
|
||||
def test_luminosity_preserves_backdrop_chroma(self):
|
||||
out = _blend("luminosity", [0.4, 0.2, 0.1], [0.5] * 3)
|
||||
assert out[0] > out[1] > out[2]
|
||||
|
||||
|
||||
class TestCompositeAndModeTable:
|
||||
def test_unknown_blend_mode_falls_back_to_normal(self):
|
||||
unknown = resolve_mode("not-a-mode")
|
||||
assert (unknown.blend_space, unknown.composite) == (
|
||||
resolve_mode("normal").blend_space,
|
||||
resolve_mode("normal").composite,
|
||||
)
|
||||
assert _blend("not-a-mode", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6]) == pytest.approx(
|
||||
_blend("normal", [0.1, 0.2, 0.3], [0.4, 0.5, 0.6])
|
||||
)
|
||||
|
||||
def test_every_blend_mode_has_a_composite_entry(self):
|
||||
for mode in set(CHANNEL_BLEND) | set(HSL_BLEND):
|
||||
resolved = resolve_mode(mode)
|
||||
assert isinstance(resolved, EffectiveMode)
|
||||
assert resolved.blend == mode
|
||||
assert resolved.blend_space in ("linear", "perceptual")
|
||||
assert resolved.composite in (
|
||||
"union",
|
||||
"clip-to-backdrop",
|
||||
"clip-to-layer",
|
||||
"intersection",
|
||||
)
|
||||
|
||||
def test_normal_over_transparent_backdrop_keeps_the_layer(self):
|
||||
backdrop = np.zeros((1, 1, 4), dtype=np.float32)
|
||||
layer = np.float32([[[0.25, 0.5, 0.75, 1.0]]])
|
||||
out = blend_composite(resolve_mode("normal"), backdrop, layer, 1.0)
|
||||
assert out[0, 0].tolist() == pytest.approx([0.25, 0.5, 0.75, 1.0])
|
||||
|
||||
def test_zero_opacity_is_a_no_op(self):
|
||||
backdrop = np.float32([[[0.1, 0.2, 0.3, 1.0]]])
|
||||
layer = np.float32([[[1.0, 1.0, 1.0, 1.0]]])
|
||||
out = blend_composite(resolve_mode("multiply"), backdrop, layer, 0.0)
|
||||
assert out[0, 0].tolist() == pytest.approx([0.1, 0.2, 0.3, 1.0])
|
||||
71
tests-unit/comfy_extras_test/compositor_node_test.py
Normal file
71
tests-unit/comfy_extras_test/compositor_node_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for ImageCompositor's handling of untrusted layer state.
|
||||
|
||||
The compositor's `compositor` widget value is persisted into the saved workflow
|
||||
and is accepted verbatim on `POST /prompt`, so every field in it is untrusted
|
||||
input, not an internal invariant.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from comfy_extras.nodes_compositor import (
|
||||
_layer_params,
|
||||
composite_from_state,
|
||||
state_from_bboxes,
|
||||
)
|
||||
|
||||
|
||||
def _solid(color, w=4, h=4) -> torch.Tensor:
|
||||
frame = np.zeros((h, w, len(color)), dtype=np.float32)
|
||||
frame[:] = color
|
||||
return torch.from_numpy(frame).unsqueeze(0)
|
||||
|
||||
|
||||
class TestLayerOpacity:
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[(-0.5, 0.0), (0.0, 0.0), (0.25, 0.25), (1.0, 1.0), (3.0, 1.0)],
|
||||
)
|
||||
def test_opacity_is_clamped(self, raw, expected):
|
||||
assert _layer_params({"opacity": raw}, 4, 4)["opacity"] == expected
|
||||
|
||||
def test_opacity_defaults_to_opaque(self):
|
||||
assert _layer_params({}, 4, 4)["opacity"] == 1.0
|
||||
|
||||
def test_out_of_range_opacity_does_not_leak_into_the_next_layer(self):
|
||||
# The canvas is only clamped once, after every layer has been composited,
|
||||
# so an out-of-range coverage multiplier on one layer changes the *blend*
|
||||
# of the layer above it. White at opacity 3.0 over black leaves the canvas
|
||||
# at 3.0; the multiply above it then reads 3.0 as its backdrop and the
|
||||
# result is visibly lighter than the same stack at opacity 1.0.
|
||||
def run(opacity):
|
||||
state = {
|
||||
"canvas": (2, 2),
|
||||
"layers": [{"opacity": opacity}, {"opacity": 1.0, "blend": "multiply"}],
|
||||
"inputs": None,
|
||||
"background": {"color": "#000000", "opacity": 1.0, "visible": True},
|
||||
"order": None,
|
||||
}
|
||||
tensors = [_solid([1.0, 1.0, 1.0], 2, 2), _solid([0.5, 0.5, 0.5], 2, 2)]
|
||||
return composite_from_state(tensors, state, [None, None])[0, 0, 0, :3]
|
||||
|
||||
assert run(3.0).tolist() == pytest.approx(run(1.0).tolist(), abs=1e-6)
|
||||
|
||||
|
||||
class TestGraphOnlyBackground:
|
||||
def test_bbox_layout_background_is_hidden(self):
|
||||
# A visible white background here would make every graph-only run emit a
|
||||
# white matte instead of transparency.
|
||||
state = state_from_bboxes([_solid([1.0, 0.0, 0.0])], [])
|
||||
assert state["background"]["visible"] is False
|
||||
|
||||
def test_uncovered_canvas_stays_transparent(self):
|
||||
tensors = [_solid([1.0, 0.0, 0.0], w=2, h=2)]
|
||||
slots = [{"x": 0, "y": 0, "width": 2, "height": 2}]
|
||||
state = state_from_bboxes(tensors, slots)
|
||||
state["canvas"] = (4, 4)
|
||||
out = composite_from_state(tensors, state, [None])[0]
|
||||
assert out.shape[-1] == 4
|
||||
assert float(out[0, 0, 3]) == pytest.approx(1.0)
|
||||
assert float(out[3, 3, 3]) == pytest.approx(0.0)
|
||||
Reference in New Issue
Block a user