"""Low-level value noise functions"""
from __future__ import annotations
import math
from collections import defaultdict
from typing import Any
import numpy as np
import tensorflow as tf
import noisemaker.masks as masks
import noisemaker.oklab as oklab
import noisemaker.rng as rng
import noisemaker.simplex as simplex
from noisemaker.constants import (
DistanceMetric,
InterpolationType,
PointDistribution,
ValueDistribution,
ValueMask,
VoronoiDiagramType,
)
from noisemaker.effects_registry import effect
from noisemaker.points import point_cloud
[docs]
def set_seed(seed: int | None) -> None:
"""
Set the random seed for noise generation.
Args:
seed: Random seed value, or None to skip seeding
Returns:
Processed tensor
"""
if seed is not None:
rng.set_seed(seed)
simplex._seed = seed
[docs]
def value_noise(count: int, freq: int = 8) -> tf.Tensor:
"""
Generate 1D value noise samples.
Args:
count: Number of samples to generate
freq: Frequency for noise generation
Returns:
Processed tensor
"""
lattice = [rng.random() for _ in range(freq + 1)]
out = []
for i in range(count):
x = i / count * freq
xi = int(x)
xf = x - xi
t = xf * xf * (3 - 2 * xf)
out.append(lattice[xi] * (1 - t) + lattice[xi + 1] * t)
return tf.constant(out, dtype=tf.float32)
[docs]
def values(
freq: int | list[int],
shape: list[int],
distrib: ValueDistribution | None = ValueDistribution.simplex,
corners: bool = False,
mask: ValueMask | None = None,
mask_inverse: bool = False,
mask_static: bool = False,
spline_order: int | InterpolationType = InterpolationType.bicubic,
time: float = 0.0,
speed: float = 1.0,
) -> tf.Tensor:
"""
Generate a tensor of noise values with specified distribution.
Args:
freq: Frequency for noise generation
shape: Shape of the tensor [height, width, channels]
distrib: Value distribution method
corners: Include corner values
mask: Value mask to apply
mask_inverse: Invert the mask
mask_static: Use static mask values
spline_order: Interpolation type for resampling
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
Returns:
Processed tensor
"""
if isinstance(spline_order, int):
spline_order = InterpolationType(spline_order)
freq_list: list[int]
if isinstance(freq, int):
freq_list = freq_for_shape(freq, shape)
else:
freq_list = freq
initial_shape: list[int] = freq_list + [shape[-1]]
if distrib is None:
distrib = ValueDistribution.simplex
distrib = coerce_enum(distrib, ValueDistribution)
mask = coerce_enum(mask, ValueMask)
if distrib == ValueDistribution.ones:
tensor = tf.ones(initial_shape, dtype=tf.float32)
elif distrib == ValueDistribution.mids:
tensor = tf.ones(initial_shape, dtype=tf.float32) * 0.5
elif distrib == ValueDistribution.zeros:
tensor = tf.zeros(initial_shape, dtype=tf.float32)
elif distrib == ValueDistribution.column_index:
tensor = tf.expand_dims(normalize(tf.cast(column_index(initial_shape), tf.float32)), -1) * tf.ones(initial_shape, dtype=tf.float32)
elif distrib == ValueDistribution.row_index:
tensor = tf.expand_dims(normalize(tf.cast(row_index(initial_shape), tf.float32)), -1) * tf.ones(initial_shape, dtype=tf.float32)
elif ValueDistribution.is_center_distance(distrib):
sdf_sides = None
if distrib == ValueDistribution.center_circle:
metric = DistanceMetric.euclidean
elif distrib == ValueDistribution.center_triangle:
metric = DistanceMetric.triangular
elif distrib == ValueDistribution.center_diamond:
metric = DistanceMetric.manhattan
elif distrib == ValueDistribution.center_square:
metric = DistanceMetric.chebyshev
elif distrib == ValueDistribution.center_pentagon:
metric = DistanceMetric.sdf
sdf_sides = 5
elif distrib == ValueDistribution.center_hexagon:
metric = DistanceMetric.hexagram
elif distrib == ValueDistribution.center_heptagon:
metric = DistanceMetric.sdf
sdf_sides = 7
elif distrib == ValueDistribution.center_octagon:
metric = DistanceMetric.octagram
elif distrib == ValueDistribution.center_nonagon:
metric = DistanceMetric.sdf
sdf_sides = 9
elif distrib == ValueDistribution.center_decagon:
metric = DistanceMetric.sdf
sdf_sides = 10
elif distrib == ValueDistribution.center_hendecagon:
metric = DistanceMetric.sdf
sdf_sides = 11
elif distrib == ValueDistribution.center_dodecagon:
metric = DistanceMetric.sdf
sdf_sides = 12
# make sure speed doesn't break looping
if speed > 0:
rounded_speed = math.floor(1 + speed)
else:
rounded_speed = math.ceil(-1 + speed)
tensor = normalized_sine(
singularity(None, shape, dist_metric=metric, sdf_sides=sdf_sides) * math.tau * max(freq_list[0], freq_list[1]) - math.tau * time * rounded_speed
) * tf.ones(shape, dtype=tf.float32)
elif ValueDistribution.is_noise(distrib):
base_seed = simplex.get_seed()
# Static value field; periodic_value below provides the animation. Passing
# an animated noise here would double-animate and produce frame-over-frame
# spikes (lattice crossings on top of the sine cycle).
value_noise = tf.cast(
simplex.simplex(initial_shape, time=0.0, seed=base_seed, speed=1),
tf.float32,
)
if speed == 0:
tensor = value_noise
else:
time_seed = (base_seed + 0x9E3779B1) & 0xFFFFFFFF
time_noise = tf.cast(
simplex.simplex(initial_shape, time=0.0, seed=time_seed, speed=1),
tf.float32,
)
scaled_time = periodic_value(time, time_noise) * speed
tensor = periodic_value(scaled_time, value_noise)
if distrib == ValueDistribution.exp:
tensor = tf.math.pow(tensor, 4)
else:
raise ValueError("%s (%s) is not a ValueDistribution" % (distrib, type(distrib)))
if mask:
atlas = masks.get_atlas(mask)
glyph_shape = freq_list + [1]
mask_values, _ = masks.mask_values(mask, glyph_shape, atlas=atlas, inverse=mask_inverse, time=0 if mask_static else time, speed=speed)
# These noise types are generated at full size, resize and pin just the mask.
if ValueDistribution.is_native_size(distrib):
mask_values = resample(mask_values, shape, spline_order=spline_order)
mask_values = pin_corners(mask_values, shape, freq_list, corners)
if shape[2] == 2:
tensor = tf.stack([tensor[:, :, 0], tf.stack(mask_values)[:, :, 0]], 2)
elif shape[2] == 4:
tensor = tf.stack([tensor[:, :, 0], tensor[:, :, 1], tensor[:, :, 2], tf.stack(mask_values)[:, :, 0]], 2)
else:
tensor *= mask_values
if not ValueDistribution.is_native_size(distrib):
tensor = resample(tensor, shape, spline_order=spline_order)
tensor = pin_corners(tensor, shape, freq_list, corners)
if distrib not in (ValueDistribution.ones, ValueDistribution.mids, ValueDistribution.zeros):
# I wish we didn't have to do this, but values out of the 0..1 range screw all kinds of things up
tensor = normalize(tensor)
return tensor
[docs]
def distance(a: tf.Tensor, b: tf.Tensor, metric: int | DistanceMetric = DistanceMetric.euclidean, sdf_sides: int = 5) -> tf.Tensor:
"""
Compute the distance from a to b, using the specified metric.
Args:
a: First value for blending/distance
b: Second value for blending/distance
metric: Distance metric to use
sdf_sides: SDF polygon sides for distance metric
Returns:
Processed tensor
"""
metric = coerce_enum(metric, DistanceMetric)
if metric == DistanceMetric.euclidean:
dist = tf.sqrt(a * a + b * b)
elif metric == DistanceMetric.manhattan:
dist = tf.abs(a) + tf.abs(b)
elif metric == DistanceMetric.chebyshev:
dist = tf.maximum(tf.abs(a), tf.abs(b))
elif metric == DistanceMetric.octagram:
dist = tf.maximum((tf.abs(a) + tf.abs(b)) / math.sqrt(2), tf.maximum(tf.abs(a), tf.abs(b)))
elif metric == DistanceMetric.triangular:
dist = tf.maximum(tf.abs(a) - b * 0.5, b)
elif metric == DistanceMetric.hexagram:
dist = tf.maximum(tf.maximum(tf.abs(a) - b * 0.5, b), tf.maximum(tf.abs(a) - b * -0.5, b * -1))
elif metric == DistanceMetric.sdf:
# https://thebookofshaders.com/07/
arctan = tf.math.atan2(a, -b) + math.pi
r = math.tau / sdf_sides
dist = tf.math.cos(tf.math.floor(0.5 + arctan / r) * r - arctan) * tf.sqrt(a * a + b * b)
else:
raise ValueError(f"{metric} isn't a distance metric.")
return dist
[docs]
@effect()
def voronoi(
tensor: tf.Tensor,
shape: list[int],
diagram_type: VoronoiDiagramType = VoronoiDiagramType.range,
nth: int = 0,
dist_metric: int | DistanceMetric = DistanceMetric.euclidean,
sdf_sides: int = 3,
alpha: float = 1.0,
with_refract: float = 0.0,
inverse: bool = False,
xy: tf.Tensor | None = None,
ridges_hint: bool = False,
refract_y_from_offset: bool = True,
time: float = 0.0,
speed: float = 1.0,
point_freq: int = 3,
point_generations: int = 1,
point_distrib: PointDistribution = PointDistribution.random,
point_drift: float = 0.0,
point_corners: bool = False,
downsample: bool = True,
) -> tf.Tensor:
"""
Create a voronoi diagram, blending with input image Tensor color values.
.. noisemaker-live::
:effect: voronoi
:input: basic
:seed: 42
:width: 512
:height: 256
:lazy:
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
diagram_type: Type of Voronoi diagram
nth: Use nth closest point
dist_metric: Distance metric to use
sdf_sides: SDF polygon sides for distance metric
alpha: Blending alpha value (0.0-1.0)
with_refract: Apply refraction amount
inverse: Invert the effect
xy: Optional XY coordinates for point cloud
ridges_hint: Apply ridge transformation hint
refract_y_from_offset: Use Y offset for refraction
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
point_freq: Point frequency for Voronoi
point_generations: Point generation count
point_distrib: Point distribution method
point_drift: Point drift amount
point_corners: Include corner points
downsample: Downsample the result
Returns:
Processed tensor
"""
diagram_type = coerce_enum(diagram_type, VoronoiDiagramType)
dist_metric = coerce_enum(dist_metric, DistanceMetric)
original_shape = shape
if downsample: # To save memory
shape = [int(shape[0] * 0.5), int(shape[1] * 0.5), shape[2]]
height, width, channels = shape
if xy is None:
if point_freq == 1:
result = point_cloud(point_freq, PointDistribution.square, shape)
if result is None:
raise ValueError("point_cloud returned None")
x, y = result
point_count = len(x)
else:
result = point_cloud(
point_freq, distrib=point_distrib, shape=shape, corners=point_corners, generations=point_generations, drift=point_drift, time=time, speed=speed
)
if result is None:
raise ValueError("point_cloud returned None")
x0, y0 = result
point_count = len(x0)
x = []
y = []
for i in range(point_count):
x.append(blend_cosine(x0[i], x0[(i + 1) % point_count], time))
y.append(blend_cosine(y0[i], y0[(i + 1) % point_count], time))
x_tensor = tf.cast(tf.stack(x), tf.float32)
y_tensor = tf.cast(tf.stack(y), tf.float32)
else:
if len(xy) == 2:
x, y = xy
point_count = len(x)
else:
x, y, point_count = xy
x_tensor = tf.cast(tf.stack(x), tf.float32)
y_tensor = tf.cast(tf.stack(y), tf.float32)
if downsample:
x_tensor /= 2.0
y_tensor /= 2.0
vshape = value_shape(shape)
x_index = tf.cast(tf.reshape(row_index(shape), vshape), tf.float32)
y_index = tf.cast(tf.reshape(column_index(shape), vshape), tf.float32)
is_triangular = dist_metric in (
DistanceMetric.triangular,
DistanceMetric.hexagram,
DistanceMetric.sdf,
)
if diagram_type in VoronoiDiagramType.flow_members():
# If we're using flow with a perfectly tiled grid, it just disappears. Perturbing the points seems to prevent this from happening.
x_tensor += rng.normal(tf.shape(x_tensor), stddev=0.0001, dtype=tf.float32)
y_tensor += rng.normal(tf.shape(y_tensor), stddev=0.0001, dtype=tf.float32)
if is_triangular:
# Keep it visually flipped "horizontal"-side-up
y_sign = -1.0 if inverse else 1.0
dist = distance((x_index - x_tensor) / width, (y_index - y_tensor) * y_sign / height, dist_metric, sdf_sides=sdf_sides)
else:
half_width = int(width * 0.5)
half_height = int(height * 0.5)
# Wrapping edges! Nearest neighbors might be actually be "wrapped around", on the opposite side of the image.
# Determine which direction is closer, and use the minimum.
# Subtracting the list of points from the index results in a new shape
# [y, x, value] - [point_count] -> [y, x, value, point_count]
x0_diff = x_index - x_tensor - half_width
x1_diff = x_index - x_tensor + half_width
y0_diff = y_index - y_tensor - half_height
y1_diff = y_index - y_tensor + half_height
#
x_diff = tf.minimum(tf.abs(x0_diff), tf.abs(x1_diff)) / width
y_diff = tf.minimum(tf.abs(y0_diff), tf.abs(y1_diff)) / height
# Not-wrapping edges!
# x_diff = (x_index - x) / width
# y_diff = (y_index - y) / height
dist = distance(x_diff, y_diff, dist_metric)
###
if diagram_type not in VoronoiDiagramType.flow_members():
dist, indices = tf.nn.top_k(dist, k=point_count)
index = min(nth + 1, point_count - 1) * -1
###
# Seamless alg offset pixels by half image size. Move results slice back to starting points with `offset`:
offset_kwargs = {
"x": 0.0 if is_triangular else half_width,
"y": 0.0 if is_triangular else half_height,
}
if diagram_type in (VoronoiDiagramType.range, VoronoiDiagramType.color_range, VoronoiDiagramType.range_regions):
range_slice = normalize(dist[:, :, index])
range_slice = tf.expand_dims(tf.sqrt(range_slice), -1)
range_slice = resample(offset(range_slice, shape, **offset_kwargs), original_shape)
if inverse:
range_slice = 1.0 - range_slice
if diagram_type in (VoronoiDiagramType.regions, VoronoiDiagramType.color_regions, VoronoiDiagramType.range_regions):
regions_slice = offset(indices[:, :, index], shape, **offset_kwargs)
###
if diagram_type == VoronoiDiagramType.range:
range_out = range_slice
if diagram_type in VoronoiDiagramType.flow_members():
dist = tf.math.log(dist)
# Clamp to avoid infinities
dist = tf.minimum(10, dist)
dist = tf.maximum(-10, dist)
dist = tf.expand_dims(dist, -1)
if diagram_type == VoronoiDiagramType.color_flow:
colors = tf.gather_nd(tensor, tf.cast(tf.stack([y * 2, x * 2], 1), tf.int32))
colors = tf.reshape(colors, [1, 1, point_count, shape[2]])
if ridges_hint:
colors = tf.abs(colors * 2 - 1)
# Trying to avoid normalize() here, since it tends to make animations twitchy.
range_out = tf.math.reduce_mean(1.0 - (1.0 - (dist * colors)), 2)
else: # flow
# Trying to avoid normalize() here, since it tends to make animations twitchy.
range_out = (tf.math.reduce_mean(dist, 2) + 1.75) / 1.45
range_out = resample(offset(range_out, shape, **offset_kwargs), original_shape)
if inverse:
range_out = 1.0 - range_out
if diagram_type in (VoronoiDiagramType.color_range, VoronoiDiagramType.range_regions):
# range_out = regions_out * range_slice
range_out = blend(tensor * range_slice, range_slice, range_slice)
if diagram_type == VoronoiDiagramType.regions:
regions_out = resample(tf.cast(regions_slice, tf.float32), original_shape, spline_order=InterpolationType.constant)
if diagram_type in (VoronoiDiagramType.color_regions, VoronoiDiagramType.range_regions):
colors = tf.gather_nd(tensor, tf.cast(tf.stack([y * 2, x * 2], 1), tf.int32))
if ridges_hint:
colors = tf.abs(colors * 2 - 1)
spline_order = 0 if diagram_type == VoronoiDiagramType.color_regions else 3
regions_out = resample(tf.reshape(tf.gather(colors, regions_slice), shape), original_shape, spline_order=spline_order)
###
if diagram_type == VoronoiDiagramType.range_regions:
out = blend(regions_out, range_out, tf.square(range_out))
elif diagram_type in [VoronoiDiagramType.range, VoronoiDiagramType.color_range] + VoronoiDiagramType.flow_members():
out = range_out
elif diagram_type in (VoronoiDiagramType.regions, VoronoiDiagramType.color_regions):
out = regions_out
else:
raise Exception(f"Not sure what to do with diagram type {diagram_type}")
if diagram_type == VoronoiDiagramType.regions:
out = tf.expand_dims(out, -1) / point_count
if with_refract != 0.0:
out = refract(tensor, original_shape, displacement=with_refract, reference_x=out, y_from_offset=refract_y_from_offset)
if tensor is not None:
out = blend(tensor, out, alpha)
return out
[docs]
def periodic_value(time: float, value: float) -> tf.Tensor:
"""
Coerce the received value to animate smoothly between time values 0 and 1, by applying a sine function and scaling the result.
Args:
time: Time value for animation (0.0-1.0)
value: Input value
Returns:
Processed tensor
"""
# h/t Etienne Jacob again
# https://bleuje.github.io/tutorial2/
return normalized_sine((time - value) * math.tau)
[docs]
def normalize(tensor: tf.Tensor, signed_range: bool = False) -> tf.Tensor:
"""
Squeeze the given Tensor into a range between 0 and 1.
Args:
tensor: Input tensor to process
signed_range: Use signed range (-1 to 1)
Returns:
Normalized tensor
"""
floor = float(tf.reduce_min(tensor))
if floor == math.inf or floor == -math.inf or floor == math.nan: # Avoid GIGO
raise ValueError(f"Input tensor contains {floor}, check caller for shenanigans")
ceil = float(tf.reduce_max(tensor))
if ceil == math.inf or ceil == -math.inf or ceil == math.nan: # Avoid GIGO
raise ValueError(f"Input tensor contains {ceil}, check caller for shenanigans")
if floor == ceil: # Avoid divide by zero
return tensor
delta = ceil - floor
values = (tensor - floor) / delta
if signed_range:
values = values * 2.0 - 1.0
return values
def _gather_scaled_offset(tensor: tf.Tensor, input_column_index: tf.Tensor, input_row_index: tf.Tensor, output_index: tf.Tensor) -> tf.Tensor:
"""
Helper function for resample(). Apply index offset to input tensor, return output_index values gathered post-offset.
Args:
tensor: Input tensor to process
input_column_index: Column indices for input
input_row_index: Row indices for input
output_index: Output index values
Returns:
Processed value
"""
return tf.gather_nd(tf.gather_nd(tensor, tf.stack([input_column_index, input_row_index], 2)), output_index)
[docs]
def resample(tensor: tf.Tensor, shape: list[int], spline_order: int | InterpolationType = 3) -> tf.Tensor:
"""
Resize an image tensor to the specified shape.
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
spline_order: Interpolation type for resampling
Returns:
Processed tensor
"""
spline_order = coerce_enum(spline_order, InterpolationType)
input_shape = tf.shape(tensor)
if input_shape[2] != shape[2]: # Channels differ; perform conversion
if input_shape[2] == 1:
if shape[2] == 2:
# Grayscale → Grayscale+Alpha: append alpha=1 channel
alpha = tf.ones_like(tensor)
tensor = tf.concat([tensor, alpha], axis=2)
elif shape[2] == 3:
# Grayscale → RGB: replicate grayscale value across R, G, B
tensor = tf.concat([tensor, tensor, tensor], axis=2)
elif shape[2] == 4:
# Grayscale → RGBA: replicate grayscale for RGB, then append alpha=1
rgb = tf.concat([tensor, tensor, tensor], axis=2)
alpha = tf.ones_like(tensor)
tensor = tf.concat([rgb, alpha], axis=2)
elif input_shape[2] == 2:
lum = tensor[..., 0:1]
alpha = tensor[..., 1:2]
if shape[2] == 1:
# Grayscale+Alpha → Grayscale: multiply lum by alpha
tensor = lum * alpha
elif shape[2] == 3:
# Grayscale+Alpha → RGB: multiply lum by alpha, then replicate for R, G, B
lum_alpha = lum * alpha
tensor = tf.concat([lum_alpha, lum_alpha, lum_alpha], axis=2)
elif shape[2] == 4:
# Grayscale+Alpha → RGBA: replicate lum for RGB, keep original alpha
rgb = tf.concat([lum, lum, lum], axis=2)
tensor = tf.concat([rgb, alpha], axis=2)
elif input_shape[2] == 3:
if shape[2] == 1:
# RGB → Grayscale: use value_map to compute luminance, keep dimensions
tensor = value_map(tensor, shape, keepdims=True)
elif shape[2] == 2:
# RGB → Grayscale+Alpha: compute grayscale, then append alpha=1
gray = value_map(tensor, shape, keepdims=True)
alpha = tf.ones_like(gray)
tensor = tf.concat([gray, alpha], axis=2)
elif shape[2] == 4:
# RGB → RGBA: append alpha=1 channel to RGB
alpha = tf.ones_like(tensor[..., 0:1])
tensor = tf.concat([tensor, alpha], axis=2)
elif input_shape[2] == 4:
rgb = tensor[..., 0:3]
alpha = tensor[..., 3:4]
if shape[2] == 1:
# RGBA → Grayscale: drop alpha, compute grayscale on RGB
tensor = value_map(rgb, shape, keepdims=True)
elif shape[2] == 2:
# RGBA → Grayscale+Alpha: compute grayscale from RGB, keep original alpha
gray = value_map(rgb, shape, keepdims=True)
tensor = tf.concat([gray, alpha], axis=2)
elif shape[2] == 3:
# RGBA → RGB: drop alpha channel
tensor = rgb
# Blown up row and column indices. These map into input tensor, producing a big blocky version.
resized_row_index = tf.cast(row_index(shape), tf.float32) * (
tf.cast(input_shape[1], tf.float32) / tf.cast(shape[1], tf.float32)
) # 0, 1, 2, 3, -> 0, 0.5, 1, 1.5A
resized_col_index = tf.cast(column_index(shape), tf.float32) * (tf.cast(input_shape[0], tf.float32) / tf.cast(shape[0], tf.float32))
# Map to input indices as int
resized_row_index_trunc = tf.floor(resized_row_index)
resized_col_index_trunc = tf.floor(resized_col_index)
resized_index_trunc = tf.cast(tf.stack([resized_col_index_trunc, resized_row_index_trunc], 2), tf.int32)
# Resized original
resized: defaultdict[int, dict[int, tf.Tensor]] = defaultdict(dict)
resized[1][1] = tf.gather_nd(tensor, resized_index_trunc)
if spline_order == InterpolationType.constant:
return resized[1][1]
# Resized neighbors
input_rows: defaultdict[int, tf.Tensor] = defaultdict(dict)
input_columns: defaultdict[int, tf.Tensor] = defaultdict(dict)
input_rows[1] = row_index(input_shape)
input_columns[1] = column_index(input_shape)
input_rows[2] = (input_rows[1] + 1) % input_shape[1]
input_columns[2] = (input_columns[1] + 1) % input_shape[0]
# Create fractional diffs (how much to blend with each neighbor)
vshape = value_shape(shape)
resized_row_index_fract = tf.reshape(resized_row_index - resized_row_index_trunc, vshape) # 0, 0.5, 1, 1.5 -> 0, .5, 0, .5
resized_col_index_fract = tf.reshape(resized_col_index - resized_col_index_trunc, vshape)
for x in range(1, 3):
for y in range(1, 3):
if x == 1 and y == 1:
continue
resized[y][x] = _gather_scaled_offset(tensor, input_columns[y], input_rows[x], resized_index_trunc)
if spline_order == InterpolationType.linear:
y1 = blend(resized[1][1], resized[1][2], resized_row_index_fract)
y2 = blend(resized[2][1], resized[2][2], resized_row_index_fract)
return blend(y1, y2, resized_col_index_fract)
if spline_order == InterpolationType.cosine:
y1 = blend_cosine(resized[1][1], resized[1][2], resized_row_index_fract)
y2 = blend_cosine(resized[2][1], resized[2][2], resized_row_index_fract)
return blend_cosine(y1, y2, resized_col_index_fract)
if spline_order == InterpolationType.bicubic:
# Extended neighborhood for bicubic
points = []
for y in range(0, 4):
if y not in input_columns:
input_columns[y] = (input_columns[1] + (y - 1)) % input_shape[0]
for x in range(0, 4):
if x not in input_rows:
input_rows[x] = (input_rows[1] + (x - 1)) % input_shape[1]
resized[y][x] = _gather_scaled_offset(tensor, input_columns[y], input_rows[x], resized_index_trunc)
points.append(blend_cubic(resized[y][0], resized[y][1], resized[y][2], resized[y][3], resized_row_index_fract))
args = points + [resized_col_index_fract]
return blend_cubic(*args)
[docs]
def proportional_downsample(tensor: tf.Tensor, shape: list[int], new_shape: list[int]) -> tf.Tensor:
"""
Given a new shape which is evenly divisible by the old shape, shrink the image by averaging pixel values.
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
new_shape: New shape for resampling
Returns:
Processed tensor
"""
kernel_shape = [max(int(shape[0] / new_shape[0]), 1), max(int(shape[1] / new_shape[1]), 1), shape[2], 1]
kernel = tf.ones(kernel_shape, dtype=tf.float32)
try:
out = tf.nn.depthwise_conv2d([tensor], kernel, [1, kernel_shape[0], kernel_shape[1], 1], "VALID")[0] / (kernel_shape[0] * kernel_shape[1])
except Exception:
out = tensor
# ValueError(f"Could not convolve with kernel shape: {kernel_shape}: {e}")
return resample(out, new_shape)
[docs]
def row_index(shape: list[int]) -> tf.Tensor:
"""
Generate an X index for the given tensor.
.. code-block:: python
[
[ 0, 1, 2, ... width-1 ],
[ 0, 1, 2, ... width-1 ],
... (x height)
]
.. image:: images/row_index.jpg
:width: 1024
:height: 256
:alt: Noisemaker example output (CC0)
Args:
shape: Shape of the tensor [height, width, channels]
Returns:
Index tensor
"""
height = shape[0]
width = shape[1]
row_identity = tf.cumsum(tf.ones([width], dtype=tf.int32), exclusive=True)
row_identity = tf.reshape(tf.tile(row_identity, [height]), [height, width])
return row_identity
[docs]
def column_index(shape: list[int]) -> tf.Tensor:
"""
Generate a Y index for the given tensor.
.. code-block:: python
[
[ 0, 0, 0, ... ],
[ 1, 1, 1, ... ],
[ n, n, n, ... ],
...
[ height-1, height-1, height-1, ... ]
]
.. image:: images/column_index.jpg
:width: 1024
:height: 256
:alt: Noisemaker example output (CC0)
Args:
shape: Shape of the tensor [height, width, channels]
Returns:
Index tensor
"""
height = shape[0]
width = shape[1]
column_identity = tf.ones([width], dtype=tf.int32)
column_identity = tf.tile(column_identity, [height])
column_identity = tf.reshape(column_identity, [height, width])
column_identity = tf.cumsum(column_identity, exclusive=True)
return column_identity
[docs]
def offset(tensor: tf.Tensor, shape: list[int], x: int | float = 0, y: int | float = 0) -> tf.Tensor:
"""
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
x: X offset amount
y: Y offset amount
Returns:
Processed tensor
"""
if x == 0 and y == 0:
return tensor
return tf.gather_nd(tensor, tf.stack([(column_index(shape) + y) % shape[0], (row_index(shape) + x) % shape[1]], 2))
def _linear_components(a: tf.Tensor, b: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Args:
a: First value for blending/distance
b: Second value for blending/distance
g: Interpolation factor (0.0-1.0)
Returns:
Processed value
"""
return a * (1 - g), b * g
[docs]
def blend(a: tf.Tensor, b: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Blend a and b values with linear interpolation.
Args:
a: First value for blending/distance
b: Second value for blending/distance
g: Interpolation factor (0.0-1.0)
Returns:
Blended tensor
"""
return sum(_linear_components(a, b, g))
def _cosine_components(a: tf.Tensor, b: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Args:
a: First value for blending/distance
b: Second value for blending/distance
g: Interpolation factor (0.0-1.0)
Returns:
Processed value
"""
# This guy is great http://paulbourke.net/miscellaneous/interpolation/
g2 = (1 - tf.cos(g * math.pi)) / 2
return a * (1 - g2), b * g2
[docs]
def blend_cosine(a: tf.Tensor, b: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Blend a and b values with cosine interpolation.
Args:
a: First value for blending/distance
b: Second value for blending/distance
g: Interpolation factor (0.0-1.0)
Returns:
Blended tensor
"""
return sum(_cosine_components(a, b, g))
def _cubic_components(a: tf.Tensor, b: tf.Tensor, c: tf.Tensor, d: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Args:
a: First value for blending/distance
b: Second value for blending/distance
c: Third value for blending
d: Fourth value for blending
g: Interpolation factor (0.0-1.0)
Returns:
Processed value
"""
# This guy is great http://paulbourke.net/miscellaneous/interpolation/
g2 = g * g
a0 = d - c - a + b
a1 = a - b - a0
a2 = c - a
a3 = b
return a0 * g * g2, a1 * g2, a2 * g + a3
[docs]
def blend_cubic(a: tf.Tensor, b: tf.Tensor, c: tf.Tensor, d: tf.Tensor, g: tf.Tensor) -> tf.Tensor:
"""
Blend b and c values with bi-cubic interpolation.
Args:
a: First value for blending/distance
b: Second value for blending/distance
c: Third value for blending
d: Fourth value for blending
g: Interpolation factor (0.0-1.0)
Returns:
Blended tensor
"""
return sum(_cubic_components(a, b, c, d, g))
[docs]
@effect()
def smoothstep(tensor: tf.Tensor, shape: list[int], a: tf.Tensor = 0.0, b: tf.Tensor = 1.0, time: float = 0.0, speed: float = 1.0) -> tf.Tensor:
"""
.. noisemaker-live::
:effect: smoothstep
:input: basic
:seed: 42
:width: 512
:height: 256
:lazy:
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
a: First value for blending/distance
b: Second value for blending/distance
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
Returns:
Processed tensor
"""
t = tf.clip_by_value((tensor - a) / (b - a), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
[docs]
def freq_for_shape(freq: int | list[int], shape: list[int]) -> tf.Tensor:
"""
Given a base frequency as int, generate noise frequencies for each spatial dimension.
Args:
freq: Frequency for noise generation
shape: Shape of the tensor [height, width, channels]
Returns:
Processed tensor
"""
if isinstance(freq, list):
freq = freq[0]
height = shape[0]
width = shape[1]
if height == width:
return [freq, freq]
elif height < width:
return [freq, int(freq * width / height)]
else:
return [int(freq * height / width), freq]
[docs]
def ridge(tensor: tf.Tensor) -> tf.Tensor:
"""
Create a "ridge" at midpoint values. 1 - abs(n * 2 - 1)
.. image:: images/crease.jpg
:width: 1024
:height: 256
:alt: Noisemaker example output (CC0)
Args:
tensor: Input tensor to process
Returns:
Processed tensor
"""
return 1.0 - tf.abs(tensor * 2 - 1)
[docs]
def simple_multires(
freq: int | list[int],
shape: list[int],
octaves: int = 1,
spline_order: int | InterpolationType = InterpolationType.bicubic,
distrib: ValueDistribution = ValueDistribution.simplex,
corners: bool = False,
ridges: bool = False,
mask: ValueMask | None = None,
mask_inverse: bool = False,
mask_static: bool = False,
time: float = 0.0,
speed: float = 1.0,
) -> tf.Tensor:
"""
Generate multi-octave value noise. Unlike generators.multires, this function is single-channel and does not apply effects.
Args:
freq: Frequency for noise generation
shape: Shape of the tensor [height, width, channels]
octaves: Number of octave layers
spline_order: Interpolation type for resampling
distrib: Value distribution method
corners: Include corner values
ridges: Apply ridge transformation
mask: Value mask to apply
mask_inverse: Invert the mask
mask_static: Use static mask values
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
Returns:
Processed tensor
"""
if isinstance(freq, int):
freq = freq_for_shape(freq, shape)
tensor = tf.zeros(shape, dtype=tf.float32)
for octave in range(1, octaves + 1):
multiplier = 2**octave
freq_list = freq if isinstance(freq, list) else [freq, freq]
base_freq = [int(f * 0.5 * multiplier) for f in freq_list]
if all(base_freq[i] > shape[i] for i in range(len(base_freq))):
break
layer = values(
freq=base_freq,
shape=shape,
spline_order=spline_order,
distrib=distrib,
corners=corners,
mask=mask,
mask_inverse=mask_inverse,
mask_static=mask_static,
time=time,
speed=speed,
)
if ridges:
layer = ridge(layer)
tensor += layer / multiplier
return normalize(tensor)
[docs]
def value_shape(shape: list[int]) -> tf.Tensor:
"""
Args:
shape: Shape of the tensor [height, width, channels]
Returns:
Processed tensor
"""
return [shape[0], shape[1], 1]
[docs]
def normalized_sine(value: float) -> tf.Tensor:
"""
Args:
value: Input value
Returns:
Normalized tensor
"""
return (tf.sin(value) + 1.0) * 0.5
def _conform_kernel_to_tensor(kernel: ValueMask, tensor: tf.Tensor, shape: list[int]) -> tf.Tensor:
"""
Re-shape a convolution kernel to match the given tensor's color dimensions.
Args:
kernel: Convolution kernel mask
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
Returns:
Processed value
"""
if isinstance(kernel, ValueMask):
values, _ = masks.mask_values(kernel)
arr = np.asarray(values, dtype=np.float32)
else:
arr = np.asarray(kernel, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 1:
arr = arr[:, :, 0]
if arr.ndim != 2:
raise ValueError("Convolution kernel must be 2-D")
height, width = arr.shape
channels = shape[-1]
tiled = np.repeat(arr[:, :, None], channels, axis=2)
temp = tf.reshape(tiled, (height, width, channels, 1))
temp = tf.cast(temp, tf.float32)
# Normalize the kernel to match the JavaScript implementation, which scales
# the filter by the largest absolute value to preserve relative weights.
denom = tf.maximum(tf.reduce_max(temp), tf.reduce_min(temp) * -1)
temp = tf.math.divide_no_nan(temp, denom)
return temp
[docs]
@effect()
def convolve(
tensor: tf.Tensor,
shape: list[int],
kernel: ValueMask = ValueMask.conv2d_blur,
with_normalize: bool = True,
alpha: float = 1.0,
time: float = 0.0,
speed: float = 1.0,
) -> tf.Tensor:
"""
Apply a convolution kernel to an image tensor.
.. code-block:: python
image = convolve(image, shape, ValueMask.conv2d_shadow)
.. noisemaker-live::
:effect: convolve
:input: basic
:seed: 42
:width: 512
:height: 256
:lazy:
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
kernel: Convolution kernel mask
with_normalize: Normalize the output
alpha: Blending alpha value (0.0-1.0)
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
Returns:
Processed tensor
"""
height, width, channels = shape
if kernel is None:
kernel = ValueMask.conv2d_blur
kernel = coerce_enum(kernel, ValueMask)
kernel_values = _conform_kernel_to_tensor(kernel, tensor, shape)
# Give the conv kernel some room to play on the edges
half_height = tf.cast(height / 2, tf.int32)
half_width = tf.cast(width / 2, tf.int32)
double_shape = [height * 2, width * 2, channels]
out = tf.tile(tensor, [2, 2, 1]) # Tile 2x2
out = offset(out, double_shape, half_width, half_height)
out = tf.nn.depthwise_conv2d([out], kernel_values, [1, 1, 1, 1], "VALID")[0]
out = tf.image.resize_with_crop_or_pad(out, height, width)
if with_normalize:
out = normalize(out)
if kernel == ValueMask.conv2d_edges:
out = tf.abs(out - 0.5) * 2
if alpha == 1.0:
return out
return blend(tensor, out, alpha)
[docs]
@effect()
def refract(
tensor: tf.Tensor,
shape: list[int],
displacement: float = 0.5,
reference_x: tf.Tensor | None = None,
reference_y: tf.Tensor | None = None,
warp_freq: int | list[int] | None = None,
spline_order: int | InterpolationType = InterpolationType.bicubic,
from_derivative: bool = False,
signed_range: bool = True,
time: float = 0.0,
speed: float = 1.0,
y_from_offset: bool = False,
) -> tf.Tensor:
"""
Apply displacement from pixel values.
.. noisemaker-live::
:effect: refract
:input: basic
:seed: 42
:width: 512
:height: 256
:lazy:
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
displacement: Displacement
reference_x: Reference x
reference_y: Reference y
warp_freq: Warp freq
spline_order: Interpolation type for resampling
from_derivative: From derivative
signed_range: Use signed range (-1 to 1)
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
y_from_offset: Y from offset
Returns:
Processed tensor
"""
height, width, channels = shape
x0_index = row_index(shape)
y0_index = column_index(shape)
warp_shape = None
if warp_freq:
warp_shape = [height, width, 1]
if reference_x is None:
if from_derivative:
reference_x = convolve(kernel=ValueMask.conv2d_deriv_x, tensor=tensor, shape=shape, with_normalize=False)
elif warp_freq:
assert warp_shape is not None
reference_x = values(freq=warp_freq, shape=warp_shape, distrib=ValueDistribution.simplex, time=time, speed=speed, spline_order=spline_order)
else:
reference_x = tensor
if reference_y is None:
if from_derivative:
reference_y = convolve(kernel=ValueMask.conv2d_deriv_y, tensor=tensor, shape=shape, with_normalize=False)
elif warp_freq:
assert warp_shape is not None
reference_y = values(freq=warp_freq, shape=warp_shape, distrib=ValueDistribution.simplex, time=time, speed=speed, spline_order=spline_order)
else:
if y_from_offset:
# "the old way"
y0_index += int(height * 0.5)
x0_index += int(width * 0.5)
reference_y = tf.gather_nd(reference_x, tf.stack([y0_index % height, x0_index % width], 2))
else:
reference_y = reference_x
reference_x = tf.cos(reference_x * math.tau)
reference_y = tf.sin(reference_y * math.tau)
reference_x = tf.clip_by_value(reference_x * 0.5 + 0.5, 0.0, 1.0)
reference_y = tf.clip_by_value(reference_y * 0.5 + 0.5, 0.0, 1.0)
quad_directional = signed_range and not from_derivative
# Use extended range so we can refract in 4 directions (-1..1) instead of 2 (0..1).
# Doesn't work with derivatives (and isn't needed), because derivatives are signed naturally.
x_offsets = value_map(reference_x, shape, signed_range=quad_directional, with_normalize=False) * displacement * tf.cast(width, tf.float32)
y_offsets = value_map(reference_y, shape, signed_range=quad_directional, with_normalize=False) * displacement * tf.cast(height, tf.float32)
# If not using extended range (0..1 instead of -1..1), keep the value range consistent.
if not quad_directional:
x_offsets *= 2.0
y_offsets *= 2.0
# Bilinear interpolation of midpoints
x_coords = tf.cast(x0_index, tf.float32)
y_coords = tf.cast(y0_index, tf.float32)
sample_x = x_coords + x_offsets
sample_y = y_coords + y_offsets
width_f = tf.cast(width, tf.float32)
height_f = tf.cast(height, tf.float32)
sample_x_wrapped = tf.math.floormod(sample_x, width_f)
sample_y_wrapped = tf.math.floormod(sample_y, height_f)
x0_base = tf.floor(sample_x_wrapped)
y0_base = tf.floor(sample_y_wrapped)
x0_base = tf.minimum(x0_base, width_f - 1)
y0_base = tf.minimum(y0_base, height_f - 1)
x0_int = tf.cast(x0_base, tf.int32)
y0_int = tf.cast(y0_base, tf.int32)
x0_offsets = tf.math.floormod(x0_int, width)
x1_offsets = tf.math.floormod(x0_int + 1, width)
y0_offsets = tf.math.floormod(y0_int, height)
y1_offsets = tf.math.floormod(y0_int + 1, height)
x0_y0 = tf.gather_nd(tensor, tf.stack([y0_offsets, x0_offsets], 2))
x1_y0 = tf.gather_nd(tensor, tf.stack([y0_offsets, x1_offsets], 2))
x0_y1 = tf.gather_nd(tensor, tf.stack([y1_offsets, x0_offsets], 2))
x1_y1 = tf.gather_nd(tensor, tf.stack([y1_offsets, x1_offsets], 2))
x_fract = tf.reshape(sample_x_wrapped - x0_base, [height, width, 1])
y_fract = tf.reshape(sample_y_wrapped - y0_base, [height, width, 1])
x_fract = tf.clip_by_value(x_fract, 0.0, 1.0)
y_fract = tf.clip_by_value(y_fract, 0.0, 1.0)
x_y0 = blend(x0_y0, x1_y0, x_fract)
x_y1 = blend(x0_y1, x1_y1, x_fract)
return blend(x_y0, x_y1, y_fract)
[docs]
def value_map(tensor: tf.Tensor, shape: list[int], keepdims: bool = False, signed_range: bool = False, with_normalize: bool = True) -> tf.Tensor:
"""
Create a grayscale value map from the given image Tensor, based on apparent luminance.
Return value ranges between 0 and 1.
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
keepdims: Keepdims
signed_range: Use signed range (-1 to 1)
with_normalize: Normalize the output
Returns:
Processed tensor
"""
# XXX Why is shape sometimes wrong when passed in from refract?
shape = tf.shape(tensor)
if shape[2] in (1, 2):
tensor = tensor[:, :, 0]
elif shape[2] == 3:
tensor = oklab.rgb_to_oklab(clamp01(tensor))[:, :, 0]
elif shape[2] == 4:
tensor = clamp01(tensor)
tensor = oklab.rgb_to_oklab(tf.stack([tensor[:, :, 0], tensor[:, :, 1], tensor[:, :, 2]], 2))[:, :, 0]
if keepdims:
tensor = tf.expand_dims(tensor, -1)
if with_normalize:
tensor = normalize(tensor, signed_range=signed_range)
elif signed_range:
tensor = tensor * 2.0 - 1.0
return tensor
[docs]
def singularity(tensor: tf.Tensor, shape: list[int], diagram_type: VoronoiDiagramType = VoronoiDiagramType.range, **kwargs: Any) -> tf.Tensor:
"""
Return the range diagram for a single voronoi point, approximately centered.
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
diagram_type: Type of Voronoi diagram
**kwargs: Additional keyword arguments for voronoi
Returns:
Processed tensor
"""
result = point_cloud(1, PointDistribution.square, shape)
if result is None:
raise ValueError("point_cloud returned None")
x, y = result
return voronoi(tensor, shape, diagram_type=diagram_type, xy=(x, y, 1), **kwargs)
[docs]
def pin_corners(tensor: tf.Tensor, shape: list[int], freq: int | list[int], corners: bool) -> tf.Tensor:
"""
Pin values to image corners, or align with image center, as per the given "corners" arg.
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
freq: Frequency for noise generation
corners: Include corner values
Returns:
Processed tensor
"""
if isinstance(freq, int):
freq = [freq, freq]
if (not corners and (freq[0] % 2) == 0) or (corners and (freq[0] % 2) == 1):
tensor = offset(tensor, shape, x=int((shape[1] / freq[1]) * 0.5), y=int((shape[0] / freq[0]) * 0.5))
return tensor
[docs]
def coerce_enum(value: Any, cls: type) -> Any:
"""
Attempt to coerce a given string or int value into an Enum instance.
Args:
value: Input value
cls: Enum class to coerce to
Returns:
Processed tensor
"""
if isinstance(value, int):
value = cls(value)
elif isinstance(value, str):
value = cls[value] # type: ignore[index]
return value
[docs]
def clamp01(tensor: tf.Tensor) -> tf.Tensor:
"""
Args:
tensor: Input tensor to process
Returns:
Processed tensor
"""
return tf.maximum(tf.minimum(tensor, 1.0), 0.0)
[docs]
@effect()
def fxaa(tensor: tf.Tensor, shape: list[int], time: float = 0.0, speed: float = 1.0) -> tf.Tensor:
"""
.. noisemaker-live::
:effect: fxaa
:input: basic
:seed: 42
:width: 512
:height: 256
:lazy:
Args:
tensor: Input tensor to process
shape: Shape of the tensor [height, width, channels]
time: Time value for animation (0.0-1.0)
speed: Animation speed multiplier
Returns:
Processed tensor
"""
# Determine the number of channels
channels = shape[2]
# Pad tensor to handle boundary conditions
padded = tf.pad(tensor, [[1, 1], [1, 1], [0, 0]], mode="REFLECT")
# Extract neighbors for all channels
center = padded[1:-1, 1:-1, :]
north = padded[:-2, 1:-1, :]
south = padded[2:, 1:-1, :]
west = padded[1:-1, :-2, :]
east = padded[1:-1, 2:, :]
if channels == 1:
# Single-channel (grayscale): use the channel itself as luma
lC = center
lN = north
lS = south
lW = west
lE = east
# Compute weights based on luminance difference
wC = 1.0
wN = tf.exp(-tf.abs(lC - lN))
wS = tf.exp(-tf.abs(lC - lS))
wW = tf.exp(-tf.abs(lC - lW))
wE = tf.exp(-tf.abs(lC - lE))
sum_w = wC + wN + wS + wW + wE + 1e-10
# Weighted blend on the single channel
result = (center * wC + north * wN + south * wS + west * wW + east * wE) / sum_w
elif channels == 2:
# Two-channel: [grayscale, alpha]
lumC = center[..., 0:1]
alpha = center[..., 1:2]
lumN = north[..., 0:1]
lumS = south[..., 0:1]
lumW = west[..., 0:1]
lumE = east[..., 0:1]
# Compute weights from grayscale channel only
wC = 1.0
wN = tf.exp(-tf.abs(lumC - lumN))
wS = tf.exp(-tf.abs(lumC - lumS))
wW = tf.exp(-tf.abs(lumC - lumW))
wE = tf.exp(-tf.abs(lumC - lumE))
sum_w = wC + wN + wS + wW + wE + 1e-10
# Blend only grayscale (first channel), keep alpha unchanged
blended_lum = (lumC * wC + lumN * wN + lumS * wS + lumW * wW + lumE * wE) / sum_w
result = tf.concat([blended_lum, alpha], axis=2)
elif channels == 3:
# Three-channel (RGB): compute luminance as NTSC weights
weights = tf.constant([0.299, 0.587, 0.114], dtype=tf.float32)
lC = tf.reduce_sum(center * weights, axis=-1, keepdims=True)
lN = tf.reduce_sum(north * weights, axis=-1, keepdims=True)
lS = tf.reduce_sum(south * weights, axis=-1, keepdims=True)
lW = tf.reduce_sum(west * weights, axis=-1, keepdims=True)
lE = tf.reduce_sum(east * weights, axis=-1, keepdims=True)
# Compute weights from luminance differences
wC = 1.0
wN = tf.exp(-tf.abs(lC - lN))
wS = tf.exp(-tf.abs(lC - lS))
wW = tf.exp(-tf.abs(lC - lW))
wE = tf.exp(-tf.abs(lC - lE))
sum_w = wC + wN + wS + wW + wE + 1e-10
# Blend RGB channels using those weights
result = (center * wC + north * wN + south * wS + west * wW + east * wE) / sum_w
elif channels == 4:
# Four-channel (RGBA): separate RGB and alpha
rgbC = center[..., 0:3]
alpha = center[..., 3:4]
rgbN = north[..., 0:3]
rgbS = south[..., 0:3]
rgbW = west[..., 0:3]
rgbE = east[..., 0:3]
# Compute luminance from RGB channels
weights = tf.constant([0.299, 0.587, 0.114], dtype=tf.float32)
lC = tf.reduce_sum(rgbC * weights, axis=-1, keepdims=True)
lN = tf.reduce_sum(rgbN * weights, axis=-1, keepdims=True)
lS = tf.reduce_sum(rgbS * weights, axis=-1, keepdims=True)
lW = tf.reduce_sum(rgbW * weights, axis=-1, keepdims=True)
lE = tf.reduce_sum(rgbE * weights, axis=-1, keepdims=True)
# Compute weights from luminance differences
wC = 1.0
wN = tf.exp(-tf.abs(lC - lN))
wS = tf.exp(-tf.abs(lC - lS))
wW = tf.exp(-tf.abs(lC - lW))
wE = tf.exp(-tf.abs(lC - lE))
sum_w = wC + wN + wS + wW + wE + 1e-10
# Blend only RGB channels; keep alpha unchanged
blended_rgb = (rgbC * wC + rgbN * wN + rgbS * wS + rgbW * wW + rgbE * wE) / sum_w
result = tf.concat([blended_rgb, alpha], axis=2)
else:
# Unexpected channel count: no-op
result = tensor
return result