Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

436 lines
16 KiB
Python

import importlib.util
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import numpy
import torch
from PIL import Image, ImageFilter
from invokeai.app.invocations.image import ImageField, OklabUnsharpMaskInvocation, OklchImageHueAdjustmentInvocation
from invokeai.app.invocations.primitives import ImageCollectionInvocation
from invokeai.backend.image_util.color_conversion import (
linear_srgb_from_oklab,
linear_srgb_from_oklch,
linear_srgb_from_srgb,
okhsl_from_srgb,
oklab_from_linear_srgb,
oklch_from_oklab,
srgb_from_hsl,
srgb_from_linear_srgb,
srgb_from_okhsl,
)
_COMPOSITION_NODES_SPEC = importlib.util.spec_from_file_location(
"invokeai.app.invocations.composition_nodes",
Path(__file__).resolve().parents[3] / "invokeai/app/invocations/composition-nodes.py",
)
assert _COMPOSITION_NODES_SPEC is not None
assert _COMPOSITION_NODES_SPEC.loader is not None
composition_nodes = importlib.util.module_from_spec(_COMPOSITION_NODES_SPEC)
_COMPOSITION_NODES_SPEC.loader.exec_module(composition_nodes)
InvokeAdjustImageHuePlusInvocation = composition_nodes.InvokeAdjustImageHuePlusInvocation
InvokeImageBlendInvocation = composition_nodes.InvokeImageBlendInvocation
def _build_context(input_image: Image.Image) -> MagicMock:
context = MagicMock()
context.images.get_pil.return_value = input_image
context.images.save.side_effect = lambda image: SimpleNamespace(
image_name="out", width=image.width, height=image.height
)
return context
def _max_abs_diff_uint8(left: Image.Image, right: Image.Image) -> int:
left_arr = numpy.asarray(left, dtype=numpy.int16)
right_arr = numpy.asarray(right, dtype=numpy.int16)
return int(numpy.abs(left_arr - right_arr).max())
def test_image_collection_invocation_preserves_existing_collection_values() -> None:
images = [ImageField(image_name="first"), ImageField(image_name="second")]
output = ImageCollectionInvocation(collection=images).invoke(MagicMock())
assert output.collection == images
def test_image_collection_invocation_appends_direct_images_after_chained_collection() -> None:
chained_images = [ImageField(image_name="chained")]
direct_images = [ImageField(image_name="direct_1"), ImageField(image_name="direct_2")]
output = ImageCollectionInvocation(collection=chained_images, images=direct_images).invoke(MagicMock())
assert output.collection == [*chained_images, *direct_images]
def test_image_collection_invocation_supports_empty_direct_images() -> None:
chained_images = [ImageField(image_name="chained")]
output = ImageCollectionInvocation(collection=chained_images, images=None).invoke(MagicMock())
assert output.collection == chained_images
def test_image_collection_invocation_outputs_empty_collection_when_inputs_are_empty() -> None:
output = ImageCollectionInvocation(collection=None, images=None).invoke(MagicMock())
assert output.collection == []
def test_oklab_unsharp_mask_invocation_preserves_alpha_and_sharpens_lightness_only() -> None:
input_image = Image.new("RGBA", (3, 1))
input_image.putdata(
[
(255, 0, 0, 32),
(0, 255, 0, 128),
(0, 0, 255, 224),
]
)
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=50.0)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
assert output.image.image_name == "out"
assert output.width == 3
assert output.height == 1
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [32, 128, 224]
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
blurred_rgb = torch.from_numpy(
numpy.asarray(input_image.convert("RGB").filter(ImageFilter.GaussianBlur(radius=1.0)), dtype=numpy.float32)
/ 255.0
).permute(2, 0, 1)
rgb_unsharp = torch.clamp(rgb + (rgb - blurred_rgb) * 0.5, 0.0, 1.0)
rgb_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(rgb))
blurred_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(blurred_rgb))
expected_oklab = rgb_oklab.clone()
expected_oklab[0, ...] = torch.clamp(
rgb_oklab[0, ...] + (rgb_oklab[0, ...] - blurred_oklab[0, ...]) * 0.5,
-1.0,
1.0,
)
oklab_unsharp = srgb_from_linear_srgb(linear_srgb_from_oklab(expected_oklab))
assert not torch.allclose(oklab_unsharp, rgb_unsharp, atol=1e-3)
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
oklab_unsharp.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_oklch_hue_adjustment_invocation_preserves_alpha_and_rotates_hue_in_oklch() -> None:
input_image = Image.new("RGBA", (2, 1))
input_image.putdata(
[
(210, 80, 30, 64),
(40, 160, 220, 192),
]
)
context = _build_context(input_image)
invocation = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
rotated_oklch = oklch.clone()
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
assert output.image.image_name == "out"
assert output.width == 2
assert output.height == 1
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [64, 192]
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_oklab_unsharp_mask_invocation_zero_strength_returns_original_image() -> None:
input_image = Image.new("RGBA", (2, 2))
input_image.putdata(
[
(12, 34, 56, 78),
(90, 123, 45, 67),
(210, 40, 80, 90),
(255, 200, 10, 255),
]
)
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.5, strength=0.0)
invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
assert _max_abs_diff_uint8(saved_image, input_image) <= 1
def test_oklab_unsharp_mask_invocation_does_not_introduce_color_on_grayscale_image() -> None:
input_image = Image.new("RGB", (3, 1))
input_image.putdata([(32, 32, 32), (128, 128, 128), (224, 224, 224)])
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=80.0)
invocation.invoke(context)
saved_image = context.images.save.call_args.kwargs["image"]
saved_rgb = numpy.asarray(saved_image.convert("RGB"), dtype=numpy.uint8)
assert numpy.abs(saved_rgb[..., 0].astype(numpy.int16) - saved_rgb[..., 1].astype(numpy.int16)).max() <= 1
assert numpy.abs(saved_rgb[..., 1].astype(numpy.int16) - saved_rgb[..., 2].astype(numpy.int16)).max() <= 1
def test_oklab_unsharp_mask_invocation_clips_extreme_values_to_valid_rgb_range() -> None:
input_image = Image.new("RGB", (3, 1))
input_image.putdata([(255, 255, 255), (0, 0, 0), (255, 255, 255)])
context = _build_context(input_image)
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=2.0, strength=500.0)
invocation.invoke(context)
saved_rgb = numpy.asarray(context.images.save.call_args.kwargs["image"].convert("RGB"), dtype=numpy.uint8)
assert saved_rgb.min() >= 0
assert saved_rgb.max() <= 255
def test_oklch_hue_adjustment_invocation_wraps_hue_values_and_supports_rgb_input() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
base_context = _build_context(input_image)
zero_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=0).invoke(base_context)
zero_saved = base_context.images.save.call_args.kwargs["image"]
full_turn_context = _build_context(input_image)
full_turn_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=360).invoke(
full_turn_context
)
full_turn_saved = full_turn_context.images.save.call_args.kwargs["image"]
negative_context = _build_context(input_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=-180).invoke(negative_context)
negative_saved = negative_context.images.save.call_args.kwargs["image"]
positive_context = _build_context(input_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180).invoke(positive_context)
positive_saved = positive_context.images.save.call_args.kwargs["image"]
assert zero_output.width == 2
assert zero_output.height == 1
assert full_turn_output.width == 2
assert full_turn_output.height == 1
assert _max_abs_diff_uint8(zero_saved, input_image) <= 1
assert _max_abs_diff_uint8(full_turn_saved, input_image) <= 1
assert _max_abs_diff_uint8(negative_saved, positive_saved) <= 1
def test_new_oklab_nodes_preserve_alpha_for_non_rgba_alpha_modes() -> None:
la_image = Image.new("LA", (2, 1))
la_image.putdata([(32, 64), (192, 224)])
unsharp_context = _build_context(la_image)
OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=25.0).invoke(unsharp_context)
unsharp_saved = unsharp_context.images.save.call_args.kwargs["image"]
hue_context = _build_context(la_image)
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=45).invoke(hue_context)
hue_saved = hue_context.images.save.call_args.kwargs["image"]
assert unsharp_saved.mode == "LA"
assert hue_saved.mode == "LA"
assert numpy.asarray(unsharp_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
assert numpy.asarray(hue_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
def test_hue_adjust_plus_oklch_uses_degree_based_oklch_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="*Oklch / Oklab",
degrees=180.0,
ok_adaptive_gamut=0.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
rotated_oklch = oklch.clone()
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
assert output.width == 2
assert output.height == 1
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_hue_adjust_plus_hsv_uses_degree_hue_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="HSV / HSL / RGB",
degrees=90.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
hsv = numpy.asarray(input_image.convert("HSV"), dtype=numpy.float32) / 255.0
hsv[..., 0] = ((hsv[..., 0] * 360.0) + 90.0) % 360.0 / 360.0
expected_rgb = Image.fromarray((hsv * 255.0).astype(numpy.uint8), mode="HSV").convert("RGB")
assert output.width == 2
assert output.height == 1
assert _max_abs_diff_uint8(saved_image.convert("RGB"), expected_rgb) <= 1
def test_hue_adjust_plus_okhsl_uses_degree_hue_contract() -> None:
input_image = Image.new("RGB", (2, 1))
input_image.putdata([(210, 80, 30), (40, 160, 220)])
context = _build_context(input_image)
invocation = InvokeAdjustImageHuePlusInvocation(
image=ImageField(image_name="in"),
space="Okhsl",
degrees=90.0,
ok_adaptive_gamut=0.0,
)
output = invocation.invoke(context)
saved_image = context.images.save.call_args.args[0]
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
okhsl = okhsl_from_srgb(rgb)
rotated_okhsl = okhsl.clone()
rotated_okhsl[0, ...] = (rotated_okhsl[0, ...] + 90.0) % 360.0
expected_rgb = srgb_from_okhsl(rotated_okhsl)
assert output.width == 2
assert output.height == 1
assert numpy.allclose(
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
expected_rgb.permute(1, 2, 0).numpy(),
atol=1 / 255.0,
)
def test_image_blend_oklch_subtract_wraps_hue_in_degrees() -> None:
invocation = InvokeImageBlendInvocation(
layer_upper=ImageField(image_name="upper"),
layer_base=ImageField(image_name="base"),
blend_mode="Subtract",
color_space="Oklch (Oklab)",
opacity=1.0,
adaptive_gamut=0.0,
)
upper_oklch = torch.tensor([[[0.0]], [[0.0]], [[20.0]]], dtype=torch.float32)
lower_oklch = torch.tensor([[[0.6]], [[0.18]], [[350.0]]], dtype=torch.float32)
expected_linear_srgb = linear_srgb_from_oklch(torch.tensor([[[0.6]], [[0.18]], [[330.0]]], dtype=torch.float32))
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
image_tensors = (
blank_rgb,
blank_rgb,
blank_rgb,
blank_rgb,
blank_alpha,
blank_alpha,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
upper_oklch,
lower_oklch,
None,
None,
None,
None,
)
blended = invocation.apply_blend(image_tensors)
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)
def test_image_blend_hsl_subtract_wraps_hue_in_degrees() -> None:
invocation = InvokeImageBlendInvocation(
layer_upper=ImageField(image_name="upper"),
layer_base=ImageField(image_name="base"),
blend_mode="Subtract",
color_space="HSL (RGB)",
opacity=1.0,
adaptive_gamut=0.0,
)
upper_hsl = torch.tensor([[[20.0]], [[0.0]], [[0.0]]], dtype=torch.float32)
lower_hsl = torch.tensor([[[350.0]], [[1.0]], [[0.5]]], dtype=torch.float32)
expected_linear_srgb = linear_srgb_from_srgb(
srgb_from_hsl(torch.tensor([[[330.0]], [[1.0]], [[0.5]]], dtype=torch.float32))
)
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
image_tensors = (
blank_rgb,
blank_rgb,
blank_rgb,
blank_rgb,
blank_alpha,
blank_alpha,
None,
None,
None,
upper_hsl,
lower_hsl,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
blended = invocation.apply_blend(image_tensors)
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)