Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,8 @@ jobs:
# Cross-stack generation contracts: the frontend writes these, Python validates them
# against the invocation and architecture registries. A frontend-only change that
# regenerates one would otherwise pass vitest while skipping the backend half.
# `__fixtures__/` does not exist yet -- it arrives with the graph-builder fixtures.
# A pattern matching nothing costs nothing here, and listing it now means the branch
# that adds the directory does not also have to remember to add this line.
# Shared Generation numerical fixtures are read by Python tests, so fixture-only changes
# must trigger this suite.
- 'invokeai/frontend/webv2/src/features/generation/core/__snapshots__/**'
- 'invokeai/frontend/webv2/src/features/generation/core/__fixtures__/**'
- 'invokeai/frontend/webv2/src/features/video/core/__snapshots__/**'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
title: Image Detail and Contrast Nodes
description: Use Clarity, Texture, and Contrast Dehaze in workflows, with their controls and image-format limits.
sidebar:
order: 6
---

Clarity, Texture, and Contrast Dehaze are image nodes for making local detail or broad contrast adjustments in a workflow. Add one from the node picker and connect an image; each node outputs a processed image.

## Choose a node

| Node | What it adjusts | Controls |
| --- | --- | --- |
| **Clarity** | Midtone detail at a broader scale. Positive amounts emphasize local detail; negative amounts soften it. | **Amount:** −200 to 200, default 0. **Size:** 4 to 1000, default 40. |
| **Texture** | Fine-scale detail. Positive amounts emphasize texture; negative amounts reduce it. | **Amount:** −200 to 200, default 0. **Size:** 1 to 100, default 8. |
| **Contrast Dehaze (approximation)** | Broad luminance separation and color intensity. Positive amounts increase both; negative amounts reduce both. | **Amount:** −200 to 200, default 0. |

For Clarity and Texture, **Size** is a scale in pixels of the input image. It controls the size of detail structures affected, not the output dimensions. Larger values affect broader structures; smaller values target finer detail. The amount is a signed strength control, not a percentage guarantee of a particular visual change. Results depend on the image.

## Image formats and alpha

These nodes accept 8-bit RGB, RGBA, grayscale (`L`), and grayscale with alpha (`LA`) images. They preserve the input dimensions and mode. When an image has alpha, the alpha channel is preserved. Fully transparent pixels retain their original hidden color values; nearby visible colors are used while processing so hidden colors do not introduce filtering edges.

An **Amount** of `0` returns an unchanged copy of the input. This is useful when wiring a node into a workflow before tuning it.

## Contrast Dehaze limits

Contrast Dehaze is an aesthetic contrast and chroma adjustment. It does not estimate atmospheric haze, recover scene depth or lost detail, or model the physical causes of haze. It is not Dark Channel Prior (DCP) and does not claim parity with proprietary dehazing tools. Strong settings can clip tonal or color detail, so compare the output with the input and use a lower amount if needed.

The implementation is first-party and built from documented image-processing equations and standard operations. No patent search or qualified patent clearance has been performed; see the [implementation provenance and licensing record](/development/guides/image-manipulation-provenance/) for details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
title: Image manipulation provenance
description: Sources, dependencies, licensing, and patent review status for the image manipulation nodes.
---

This record covers the image manipulation invocation and its shared numerical helpers. The table is the per-file provenance manifest; each row has the same origin, notice, and license status stated below.

## Sources and implementation

The image color-space conversion follows the published [Oklab model and reference equations](https://bottosson.github.io/posts/oklab/).
The scalar edge-aware smoothing follows the guided-filter formulation of He, Sun, and Tang, [Guided Image Filtering (ECCV 2010)](https://doi.org/10.1007/978-3-642-15549-9_1), using normalized box means and reflected boundaries. The sRGB transfer function follows the [CSS Color Module Level 4 definition](https://www.w3.org/TR/css-color-4/#predefined-sRGB).

The clarity, texture, and contrast-dehaze controls are first-party image adjustments built from these documented equations and standard multiscale image-processing operations. Contrast Dehaze is an aesthetic approximation: it is not a physical haze estimator or scene-recovery method. The candidate contains no Dark Channel Prior (DCP) implementation or DCP control.

The references below identify mathematical and specification foundations only. They are not code sources.

| Implementation file | Purpose and algorithm/source basis | Libraries and owner |
| --- | --- | --- |
| `invokeai/app/invocations/image_manipulation.py` | InvokeAI node adapters for Clarity, Texture, and Contrast Dehaze; no external algorithm code. | InvokeAI invocation/image services and the three first-party backend helpers. |
| `invokeai/backend/image_util/clarity.py` | Midtone-gated, edge-aware multiscale detail adjustment; guided-filter formulation and Oklab lightness. | NumPy, Pillow; delegates to the `color.py` and `multiscale.py` helpers. |
| `invokeai/backend/image_util/color.py` | Oklab lightness conversion and image restoration; Oklab equations and sRGB transfer function. | NumPy, Pillow, PyTorch, OpenCV core APIs (`cv2`). |
| `invokeai/backend/image_util/contrast_dehaze.py` | First-party broad-scale contrast and chroma adjustment using guided-filter bands and Oklab. | NumPy, Pillow; delegates to the `color.py` and `multiscale.py` helpers. |
| `invokeai/backend/image_util/guided_filter.py` | Scalar guided image filter; He, Sun, and Tang's guided-filter formulation, implemented with normalized OpenCV box filters. | NumPy, OpenCV core APIs (`cv2`). |
| `invokeai/backend/image_util/multiscale.py` | Difference between two self-guided smooths; built on the guided-filter formulation. | NumPy; delegates to `guided_filter.py`. |
| `invokeai/backend/image_util/texture.py` | Fine-scale detail adjustment with a local RMS gate; guided-filter bands and Oklab lightness. | NumPy, Pillow, OpenCV core APIs (`cv2`); delegates to the `color.py` and `multiscale.py` helpers. |
| `invokeai/frontend/webv2/src/features/generation/core/colorConversion.ts` | sRGB-to-Oklab conversion; published Oklab equations and sRGB transfer function. | TypeScript and standard ECMAScript numeric arrays/math. |
| `invokeai/frontend/webv2/src/features/generation/core/guidedFilter.ts` | Scalar guided filter with reflected boundaries; He, Sun, and Tang's guided-filter formulation. | TypeScript and standard ECMAScript numeric arrays/math. |
| `invokeai/frontend/webv2/src/features/generation/core/multiscale.ts` | Difference between guided-filter smooths; guided-filter formulation. | TypeScript and standard ECMAScript numeric arrays/math; delegates to `guidedFilter.ts`. |

For **every file in the table**, the implementation was authored for InvokeAI, with no individual author asserted. No code was copied or adapted from the cited works, their reference implementations, or other external code. Each file is licensed under `Apache-2.0` (SPDX identifier in the source); no third-party code or third-party notices are included in these files. The Python modules call OpenCV core APIs only. The project already declares the `opencv-contrib-python` distribution, but this feature uses no contrib-only API and adds no contrib requirement or other dependency.

## License and patent status

No patent search or qualified patent clearance has been performed for the files in this manifest; this record makes no representation that the described techniques are patent-free. None of the listed files implements or exposes Dark Channel Prior (DCP). Any future inclusion or distribution of DCP, or upstream submission containing DCP, is gated on qualified patent and provenance clearance, as required by the project checklist.
72 changes: 72 additions & 0 deletions invokeai/app/invocations/image_manipulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# SPDX-License-Identifier: Apache-2.0
"""First-party image manipulation invocations."""

from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.image_util.clarity import clarity
from invokeai.backend.image_util.contrast_dehaze import contrast_dehaze
from invokeai.backend.image_util.texture import texture


@invocation(
"clarity",
title="Clarity",
tags=["image", "detail", "clarity", "enhance"],
category="image",
version="1.0.1",
)
class ClarityInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Adjust local midtone detail in an image."""

image: ImageField = InputField(description="The image to process")
amount: float = InputField(default=0, ge=-200, le=200, description="Strength of the clarity adjustment")
size: float = InputField(default=40, ge=4, le=1000, description="Scale of the detail adjustment")

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
result = clarity(image, self.amount, self.size)
image_dto = context.images.save(image=result)
return ImageOutput.build(image_dto)


@invocation(
"texture",
title="Texture",
tags=["image", "detail", "texture", "enhance"],
category="image",
version="1.0.1",
)
class TextureInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Adjust fine-scale texture detail in an image."""

image: ImageField = InputField(description="The image to process")
amount: float = InputField(default=0, ge=-200, le=200, description="Strength of the texture adjustment")
size: float = InputField(default=8, ge=1, le=100, description="Scale of the texture detail")

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
result = texture(image, self.amount, self.size)
image_dto = context.images.save(image=result)
return ImageOutput.build(image_dto)


@invocation(
"contrast_dehaze",
title="Contrast Dehaze (approximation)",
tags=["image", "contrast", "dehaze", "enhance"],
category="image",
version="1.0.1",
)
class ContrastDehazeInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Adjust broad-scale contrast and chroma as an aesthetic dehazing approximation."""

image: ImageField = InputField(description="The image to process")
amount: float = InputField(default=0, ge=-200, le=200, description="Strength of the contrast and dehaze adjustment")

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
result = contrast_dehaze(image, self.amount)
image_dto = context.images.save(image=result)
return ImageOutput.build(image_dto)
47 changes: 47 additions & 0 deletions invokeai/backend/image_util/clarity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SPDX-License-Identifier: Apache-2.0
"""Edge-aware midtone detail adjustment."""

from __future__ import annotations

from math import isfinite
from numbers import Real

import numpy as np
from PIL import Image

from invokeai.backend.image_util.color import prepare_lightness, restore_lightness
from invokeai.backend.image_util.multiscale import edge_aware_band

_MIN_AMOUNT = -200
_MAX_AMOUNT = 200
_MIN_SIZE = 4
_MAX_SIZE = 1000
_GUIDED_FILTER_EPSILON = 0.002


def clarity(source: Image.Image, amount: float = 0, size: float = 40) -> Image.Image:
"""Adjust local midtone detail while preserving the source image's color and alpha."""
if isinstance(amount, bool) or not isinstance(amount, Real) or not isfinite(amount):
raise ValueError("amount must be a finite number between -200 and 200")
amount_value = float(amount)
if not _MIN_AMOUNT <= amount_value <= _MAX_AMOUNT:
raise ValueError("amount must be between -200 and 200")

if isinstance(size, bool) or not isinstance(size, Real) or not isfinite(size):
raise ValueError("size must be a finite number between 4 and 1000")
size_value = float(size)
if not _MIN_SIZE <= size_value <= _MAX_SIZE:
raise ValueError("size must be between 4 and 1000")

if amount_value == 0:
return source.copy()

color_tensor, lightness, alpha = prepare_lightness(source)
inner_radius = max(1, round(size_value / 4))
outer_radius = max(inner_radius + 1, round(size_value))
detail = edge_aware_band(lightness, inner_radius, outer_radius, _GUIDED_FILTER_EPSILON)
clipped_lightness = np.clip(lightness, 0.0, 1.0)
midtone_gate = 4.0 * clipped_lightness * (1.0 - clipped_lightness)
adjusted_lightness = np.asarray(lightness + (amount_value / 100.0) * midtone_gate * detail, dtype=np.float32)

return restore_lightness(source, color_tensor, adjusted_lightness, alpha)
130 changes: 130 additions & 0 deletions invokeai/backend/image_util/color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations

import numpy as np
import torch
from PIL import Image

from invokeai.backend.image_util.color_conversion import (
gamut_clip_tensor,
linear_srgb_from_oklab,
oklab_from_srgb,
srgb_from_linear_srgb,
)

_SUPPORTED_MODES = {"RGB", "RGBA", "L", "LA"}
_RESTORE_TILE_ROWS = 256


def _check_image(image: Image.Image) -> None:
if not isinstance(image, Image.Image):
raise TypeError("image must be a Pillow Image")
if image.mode not in _SUPPORTED_MODES:
raise ValueError(f"Unsupported image mode: {image.mode}")


def prepare_lightness(image: Image.Image) -> tuple[torch.Tensor, np.ndarray, np.ndarray | None]:
"""Convert an 8-bit Pillow image to Oklab, lightness, and optional alpha."""
try:
_check_image(image)
width, height = image.size
rgb = np.asarray(image.convert("RGB"), dtype=np.uint8).copy()
alpha: np.ndarray | None = None
if image.mode in {"RGBA", "LA"}:
alpha = np.asarray(image.getchannel("A"), dtype=np.uint8).copy()
hidden = alpha == 0
visible = ~hidden
if hidden.any():
rgb[hidden] = 0
if visible.any():
import cv2

_, labels = cv2.distanceTransformWithLabels(
np.ascontiguousarray(hidden, dtype=np.uint8),
cv2.DIST_L2,
5,
labelType=cv2.DIST_LABEL_PIXEL,
)
visible_labels = labels[visible]
label_to_rgb = np.zeros((int(labels.max()) + 1, 3), dtype=np.uint8)
label_to_rgb[visible_labels] = rgb[visible]
rgb[hidden] = label_to_rgb[labels[hidden]]

rgb_tensor = torch.from_numpy(rgb).permute(2, 0, 1).contiguous().to(dtype=torch.float32).div_(255.0)
oklab = oklab_from_srgb(rgb_tensor).contiguous()
lightness = np.ascontiguousarray(oklab[0].numpy(), dtype=np.float32)
if oklab.shape != (3, height, width):
raise RuntimeError("Color conversion returned an unexpected image shape")
return oklab, lightness, alpha
except MemoryError as exc:
raise RuntimeError("Not enough memory to prepare image color data") from exc


def restore_lightness(
image: Image.Image,
color_tensor: torch.Tensor,
lightness: np.ndarray,
alpha: np.ndarray | None,
) -> Image.Image:
"""Replace Oklab lightness and restore the result in the image's original mode."""
try:
_check_image(image)
width, height = image.size
if not isinstance(color_tensor, torch.Tensor) or color_tensor.shape != (3, height, width):
raise ValueError("color_tensor must have shape (3, H, W) matching the image")
if color_tensor.device.type != "cpu" or color_tensor.dtype != torch.float32:
raise ValueError("color_tensor must be a CPU float32 tensor")
if not isinstance(lightness, np.ndarray) or lightness.shape != (height, width) or lightness.dtype != np.float32:
raise ValueError("lightness must be an HxW float32 NumPy array matching the image")

source_alpha: np.ndarray | None = None
if image.mode in {"RGBA", "LA"}:
if not isinstance(alpha, np.ndarray) or alpha.shape != (height, width) or alpha.dtype != np.uint8:
raise ValueError("alpha must be an HxW uint8 array matching the image")
source_alpha = np.ascontiguousarray(alpha)
elif alpha is not None:
raise ValueError("alpha must be None for images without an alpha channel")

original = np.asarray(image, dtype=np.uint8) if source_alpha is not None else None
grayscale = image.mode in {"L", "LA"}
restored = np.empty((height, width) if grayscale else (height, width, 3), dtype=np.uint8)
for row_start in range(0, height, _RESTORE_TILE_ROWS):
row_end = min(row_start + _RESTORE_TILE_ROWS, height)
tile_color = color_tensor[:, row_start:row_end]
if not torch.isfinite(tile_color).all():
raise ValueError("color_tensor must contain only finite values")
tile_lightness = lightness[row_start:row_end]
if not np.isfinite(tile_lightness).all():
raise ValueError("lightness must contain only finite values")

adjusted = tile_color.clone()
adjusted[0] = torch.from_numpy(np.ascontiguousarray(tile_lightness)).clamp(0.0, 1.0)
linear_rgb = linear_srgb_from_oklab(adjusted)
if not torch.isfinite(linear_rgb).all():
raise ValueError("lightness produced non-finite RGB values")
if torch.any((linear_rgb < 0.0) | (linear_rgb > 1.0)):
linear_rgb = gamut_clip_tensor(linear_rgb)
srgb = srgb_from_linear_srgb(linear_rgb).clamp(0.0, 1.0)
if grayscale:
gray = srgb[0] * 0.299 + srgb[1] * 0.587 + srgb[2] * 0.114
restored[row_start:row_end] = gray.mul(255.0).round().to(torch.uint8).numpy()
if source_alpha is not None and original is not None:
hidden = source_alpha[row_start:row_end] == 0
restored[row_start:row_end][hidden] = original[row_start:row_end][hidden, 0]
else:
restored[row_start:row_end] = (
srgb.mul(255.0).round().to(torch.uint8).permute(1, 2, 0).contiguous().numpy()
)
if source_alpha is not None and original is not None:
hidden = source_alpha[row_start:row_end] == 0
restored[row_start:row_end][hidden] = original[row_start:row_end][hidden, :3]

if grayscale:
if source_alpha is not None:
return Image.fromarray(np.dstack((restored, source_alpha)), mode="LA")
return Image.fromarray(restored, mode="L")
if source_alpha is not None:
return Image.fromarray(np.dstack((restored, source_alpha)), mode="RGBA")
return Image.fromarray(restored, mode="RGB")
except MemoryError as exc:
raise RuntimeError("Not enough memory to restore image color data") from exc
Loading
Loading