Files

853 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import torch
import torch.nn.functional as F
import numpy as np
def gaussian_kernel1d(size: int, sigma: float, device, dtype):
"""
Generate a 1D Gaussian kernel.
Args:
size: Size of the kernel.
sigma: Standard deviation for the kernel.
device: Device for the kernel.
dtype: Data type for the kernel.
"""
x = torch.arange(size, device=device, dtype=dtype) - (size - 1) / 2
k = torch.exp(-0.5 * (x / sigma) ** 2)
k = k / k.sum()
return k
def gaussian_blur(img: torch.Tensor, ksize: int, sigma: float):
"""
Apply Gaussian blur to an image tensor.
Args:
img: Input image tensor of shape (N, C, H, W).
ksize: Kernel size for blurring.
sigma: Standard deviation for blurring.
"""
if ksize <= 1 or sigma <= 0:
return img
dtype = img.dtype
device = img.device
k1d = gaussian_kernel1d(ksize | 1, sigma, device, dtype)
kx = k1d.view(1, 1, 1, -1)
ky = k1d.view(1, 1, -1, 1)
c = img.shape[1]
img = F.conv2d(img, kx.expand(c, 1, 1, -1), padding=(0, (ksize | 1) // 2), groups=c)
img = F.conv2d(img, ky.expand(c, 1, -1, 1), padding=(((ksize | 1) // 2), 0), groups=c)
return img
def unsharp_mask(img: torch.Tensor, sigma: float, amount: float, threshold: float = 0.0):
"""
Unsharp mask for tensors shaped (N,C,H,W) or (1,1,H,W), using Gaussian blur.
Args:
img: Input tensor in [0,1] (expected), shape (N,C,H,W).
sigma: Gaussian sigma (radius proxy). If <= 0, no sharpening is applied.
amount: Sharpening amount (gain) typically in [0..2].
threshold: Only enhance details where |img - blur| > threshold. Range [0..1].
Returns:
Sharpened tensor, clamped to [0,1].
"""
if sigma <= 0.0 or amount == 0.0:
return img
# Compute a reasonable odd kernel size from sigma
# k ~ 2*ceil(3*sigma)+1 ensures sufficient support
if sigma < 0.2:
ksize = 1
else:
k_est = int(2 * math.ceil(3.0 * float(sigma)) + 1)
ksize = max(3, k_est | 1)
base = img
# Ensure shape is NCHW
need_unsqueeze = False
if base.dim() == 3:
base = base.unsqueeze(0)
need_unsqueeze = True
if base.dim() != 4:
# Fallback: do nothing for unsupported shapes
return img
blurred = gaussian_blur(base, ksize=ksize, sigma=float(sigma))
high = base - blurred
if threshold > 0.0:
mask = (high.abs() > float(threshold)).to(base.dtype)
high = high * mask
out = base + float(amount) * high
if need_unsqueeze:
out = out.squeeze(0)
return out
def _depthwise_conv(img: torch.Tensor, kernel: torch.Tensor):
"""Depthwise conv2d for NCHW with per-channel same kernel."""
c = img.shape[1]
k = kernel.to(dtype=img.dtype, device=img.device)
k = k.view(1, 1, *k.shape).expand(c, 1, k.shape[-2], k.shape[-1])
pad_h = k.shape[-2] // 2
pad_w = k.shape[-1] // 2
return F.conv2d(img, k, padding=(pad_h, pad_w), groups=c)
def sobel_magnitude_nchw(img: torch.Tensor):
"""
Sobel gradient magnitude for NCHW tensor. Returns (N,1,H,W) normalized to [0,1].
"""
if img.dim() != 4:
raise ValueError("sobel_magnitude_nchw expects NCHW tensor")
# Kernels
kx = torch.tensor([[1, 0, -1], [2, 0, -2], [1, 0, -1]])
ky = torch.tensor([[1, 2, 1], [0, 0, 0], [-1, -2, -1]])
gx = _depthwise_conv(img, kx)
gy = _depthwise_conv(img, ky)
mag = torch.sqrt(gx * gx + gy * gy + 1e-12)
mag = mag.mean(dim=1, keepdim=True)
mag = (mag - mag.amin(dim=(-2, -1), keepdim=True)) / (mag.amax(dim=(-2, -1), keepdim=True) - mag.amin(dim=(-2, -1), keepdim=True) + 1e-12)
return mag
def laplacian_magnitude_nchw(img: torch.Tensor):
"""Laplacian-of-Gaussian-lite: plain Laplacian magnitude per-pixel, normalized to [0,1], shape (N,1,H,W)."""
if img.dim() != 4:
raise ValueError("laplacian_magnitude_nchw expects NCHW tensor")
k = torch.tensor([[0, 1, 0], [1, -4, 1], [0, 1, 0]])
lo = _depthwise_conv(img, k)
mag = lo.abs().mean(dim=1, keepdim=True)
mag = (mag - mag.amin(dim=(-2, -1), keepdim=True)) / (mag.amax(dim=(-2, -1), keepdim=True) - mag.amin(dim=(-2, -1), keepdim=True) + 1e-12)
return mag
def local_variance_nchw(img: torch.Tensor, ksize: int = 7):
"""
Local variance over a sliding window per channel, averaged across channels, normalized to [0,1].
"""
if img.dim() != 4:
raise ValueError("local_variance_nchw expects NCHW tensor")
k = max(3, int(ksize) | 1)
c = img.shape[1]
device, dtype = img.device, img.dtype
w = torch.ones((1, 1, k, k), device=device, dtype=dtype) / float(k * k)
w = w.expand(c, 1, k, k)
pad = (k // 2, k // 2)
mean = F.conv2d(img, w, padding=pad, groups=c)
mean2 = F.conv2d(img * img, w, padding=pad, groups=c)
var = (mean2 - mean * mean).clamp_min(0.0)
var = var.mean(dim=1, keepdim=True)
var = (var - var.amin(dim=(-2, -1), keepdim=True)) / (var.amax(dim=(-2, -1), keepdim=True) - var.amin(dim=(-2, -1), keepdim=True) + 1e-12)
return var
def perlin_noise(h, w, scale, octaves, persistence, lacunarity, seed, device, dtype):
"""
Generate Perlin noise.
Args:
h: Height of the noise.
w: Width of the noise.
scale: Scale of the noise.
octaves: Number of octaves for the noise.
persistence: Persistence of the noise.
lacunarity: Lacunarity of the noise.
seed: Seed for the noise.
device: Device for the noise.
dtype: Data type for the noise.
"""
if scale <= 0:
scale = 1.0
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
def _gradients(gh, gw):
theta = torch.rand((gh + 1, gw + 1), generator=rng, device=device, dtype=dtype) * 2 * math.pi
return torch.stack((torch.cos(theta), torch.sin(theta)), dim=-1)
def _perlin2d(size_y, size_x, freq_y, freq_x):
gh = int(math.floor(freq_y)) + 1
gw = int(math.floor(freq_x)) + 1
g = _gradients(gh, gw)
y = torch.linspace(0, freq_y, steps=size_y, device=device, dtype=dtype)
x = torch.linspace(0, freq_x, steps=size_x, device=device, dtype=dtype)
yi = y.floor().long()
xi = x.floor().long()
yf = (y - yi).unsqueeze(1).expand(size_y, size_x)
xf = (x - xi).unsqueeze(0).expand(size_y, size_x)
def _g(ix, iy):
return g[iy.clamp_max(gh), ix.clamp_max(gw)]
x0 = xi.unsqueeze(0).expand(size_y, size_x)
y0 = yi.unsqueeze(1).expand(size_y, size_x)
g00 = _g(x0, y0)
g10 = _g(x0 + 1, y0)
g01 = _g(x0, y0 + 1)
g11 = _g(x0 + 1, y0 + 1)
d00 = torch.stack((xf, yf), dim=-1)
d10 = torch.stack((xf - 1.0, yf), dim=-1)
d01 = torch.stack((xf, yf - 1.0), dim=-1)
d11 = torch.stack((xf - 1.0, yf - 1.0), dim=-1)
n00 = (g00 * d00).sum(dim=-1)
n10 = (g10 * d10).sum(dim=-1)
n01 = (g01 * d01).sum(dim=-1)
n11 = (g11 * d11).sum(dim=-1)
def _fade(t): return t * t * t * (t * (t * 6 - 15) + 10)
u = _fade(xf)
v = _fade(yf)
nx0 = n00 * (1 - u) + n10 * u
nx1 = n01 * (1 - u) + n11 * u
nxy = nx0 * (1 - v) + nx1 * v
return nxy
yfreq = max(h / scale, 1.0)
xfreq = max(w / scale, 1.0)
amp = 1.0
freq_mul = 1.0
total = torch.zeros((h, w), device=device, dtype=dtype)
maxamp = 0.0
for _ in range(max(1, octaves)):
total += amp * _perlin2d(h, w, yfreq * freq_mul, xfreq * freq_mul)
maxamp += amp
amp *= persistence
freq_mul *= lacunarity
total = total / maxamp if maxamp > 0 else total
total = (total - total.min()) / (total.max() - total.min() + 1e-12)
return total
def bayer_matrix(size: int, device, dtype):
"""
Generate a simple Bayer matrix.
Args:
size: Size of the matrix.
device: Device for the matrix.
dtype: Data type for the matrix.
"""
base2 = torch.tensor([[0, 2], [3, 1]], device=device, dtype=dtype)
if size <= 2:
m = base2
elif size <= 4:
m = torch.cat([torch.cat([4*base2+0, 4*base2+2], dim=1),
torch.cat([4*base2+3, 4*base2+1], dim=1)], dim=0)
else:
m2 = torch.cat([torch.cat([4*base2+0, 4*base2+2], dim=1),
torch.cat([4*base2+3, 4*base2+1], dim=1)], dim=0)
rh = size // m2.shape[0] + (1 if size % m2.shape[0] else 0)
rw = size // m2.shape[1] + (1 if size % m2.shape[1] else 0)
m = m2.repeat(rh, rw)[:size, :size]
m = (m - m.min()) / (m.max() - m.min() + 1e-12)
return m
def _radial_frequency_grid(h: int, w: int, device, dtype):
"""
Build a radial spatial-frequency grid in cycles/pixel for 2D FFT shaping.
Returns tensor of shape (h, w) with radius r = sqrt(fx^2 + fy^2).
"""
fy = torch.fft.fftfreq(h, d=1.0, device=device)
fx = torch.fft.fftfreq(w, d=1.0, device=device)
# Use float dtype for operations, then cast to requested dtype at the end
Fy = fy.reshape(h, 1).to(torch.float64)
Fx = fx.reshape(1, w).to(torch.float64)
r = torch.sqrt(Fx * Fx + Fy * Fy)
return r.to(dtype=dtype, device=device)
def spectral_noise(h: int, w: int, beta: float, seed: int, device, dtype):
"""
Generate 2D spatial noise with power spectral density S(f) ∝ f^beta.
beta = 0 -> white
beta = -1 -> pink (1/f)
beta = -2 -> brown/red (1/f^2)
beta = +1 -> blue (f)
beta = +2 -> violet/purple (f^2)
"""
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
base = torch.randn((h, w), generator=rng, device=device, dtype=dtype)
F = torch.fft.fft2(base)
r = _radial_frequency_grid(h, w, device, dtype)
r = torch.clamp(r, min=1e-6)
amp = torch.pow(r, beta * 0.5)
F_shaped = F * amp
x = torch.fft.ifft2(F_shaped).real
x = (x - x.min()) / (x.max() - x.min() + 1e-12)
return x
# -------------------------
# Affine schedule utilities
# -------------------------
def _ease_value(t, curve, back_k=1.70158, bezier=None):
"""Return eased value in [0,1] for t in [0,1] given curve name.
Supported curves: linear, easeIn/Out/InOut Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, cubicBezier.
"""
t = max(0.0, min(1.0, float(t)))
c = (curve or "easeInOutSine").lower()
# Linear
if c == "linear":
return t
# Sine
if c == "easeinsine":
return 1 - math.cos((t * math.pi) / 2)
if c == "easeoutsine":
return math.sin((t * math.pi) / 2)
if c == "easeinoutsine":
return -(math.cos(math.pi * t) - 1) / 2
# Power families
pow_map = {"easeinquad": 2, "easeincubic": 3, "easeinquart": 4, "easeinquint": 5}
if c in pow_map:
return t ** pow_map[c]
if c in {"easeoutquad", "easeoutcubic", "easeoutquart", "easeoutquint"}:
p = {"easeoutquad": 2, "easeoutcubic": 3, "easeoutquart": 4, "easeoutquint": 5}[c]
return 1 - (1 - t) ** p
if c in {"easeinoutquad", "easeinoutcubic", "easeinoutquart", "easeinoutquint"}:
p = {"easeinoutquad": 2, "easeinoutcubic": 3, "easeinoutquart": 4, "easeinoutquint": 5}[c]
if t < 0.5:
return (2 * t) ** p / 2
return 1 - ((-2 * t + 2) ** p) / 2
# Expo
if c == "easeinexpo":
return 0 if t == 0 else 2 ** (10 * t - 10)
if c == "easeoutexpo":
return 1 if t == 1 else 1 - 2 ** (-10 * t)
if c == "easeinoutexpo":
if t == 0 or t == 1:
return t
if t < 0.5:
return (2 ** (20 * t - 10)) / 2
return (2 - 2 ** (-20 * t + 10)) / 2
# Circ
if c == "easeincirc":
return 1 - math.sqrt(1 - t * t)
if c == "easeoutcirc":
return math.sqrt(1 - (t - 1) ** 2)
if c == "easeinoutcirc":
if t < 0.5:
return (1 - math.sqrt(1 - (2 * t) ** 2)) / 2
return (math.sqrt(1 - (-2 * t + 2) ** 2) + 1) / 2
# Back
if c == "easeinback":
k = back_k
return (k + 1) * t ** 3 - k * t ** 2
if c == "easeoutback":
k = back_k
return 1 + (k + 1) * (t - 1) ** 3 + k * (t - 1) ** 2
if c == "easeinoutback":
k = back_k * 1.525
if t < 0.5:
return ((2 * t) ** 2 * ((k + 1) * 2 * t - k)) / 2
return (((2 * t - 2) ** 2) * ((k + 1) * (t * 2 - 2) + k) + 2) / 2
# Cubic Bezier (optional) – approximate on y
if c == "cubicbezier" and bezier is not None:
p1x, p1y, p2x, p2y = bezier
u = 1 - t
by = (u ** 3) * 0 + 3 * (u ** 2) * t * p1y + 3 * u * (t ** 2) * p2y + (t ** 3) * 1
return by
return t
def affine_step_schedule(steps: int, sched: dict):
"""Build per-step multipliers [0..1] using easing and shaping options dict.
Keys: start, end, bias, exponent, start_offset, end_offset, curve, back_k, bezier.
"""
start = float(sched.get("start", 0.2))
end = float(sched.get("end", 0.8))
bias = float(sched.get("bias", 0.5))
exponent = float(sched.get("exponent", 1.0))
start_offset = float(sched.get("start_offset", 0.0))
end_offset = float(sched.get("end_offset", 0.0))
curve = str(sched.get("curve", "easeInOutSine"))
back_k = float(sched.get("back_k", 1.70158))
bez = sched.get("bezier", None)
start = min(max(0.0, start), 1.0)
end = min(max(0.0, end), 1.0)
if steps <= 0:
return np.zeros((0,), dtype=np.float32)
start, end = (start, end) if start <= end else (end, start)
mid = start + bias * (end - start)
multipliers = np.zeros(steps, dtype=np.float32)
start_idx, mid_idx, end_idx = [int(round(x * max(steps - 1, 0))) for x in [start, mid, end]]
# Rising 0->1 eased
if mid_idx >= start_idx:
count = (mid_idx - start_idx) + 1
ts = np.linspace(0.0, 1.0, count, dtype=np.float32)
ev = np.array([_ease_value(float(t), curve, back_k=back_k, bezier=bez) for t in ts], dtype=np.float32)
ev = np.power(ev, max(0.0, exponent))
if ev.size > 0:
ev = ev * (1.0 - start_offset) + start_offset
multipliers[start_idx:mid_idx + 1] = ev
# Falling 1->0 eased (mirror)
if end_idx >= mid_idx:
count = (end_idx - mid_idx) + 1
ts = np.linspace(1.0, 0.0, count, dtype=np.float32)
ev = np.array([_ease_value(float(t), curve, back_k=back_k, bezier=bez) for t in ts], dtype=np.float32)
ev = np.power(ev, max(0.0, exponent))
if ev.size > 0:
ev = ev * (1.0 - end_offset) + end_offset
multipliers[mid_idx:end_idx + 1] = ev
multipliers[:start_idx] = start_offset
multipliers[end_idx + 1:] = end_offset
return multipliers.astype(np.float32)
def _rand_generator(seed: int, device):
g = torch.Generator(device=device)
if seed is not None:
g.manual_seed(seed)
return g
def _chunked_pairwise_min_dists(px: torch.Tensor, pts: torch.Tensor, k: int = 1, chunk_pts: int = 256, chunk_px: int = 65536):
"""
Compute k smallest distances from each pixel to points with chunking.
px: (P,2), pts: (N,2)
Returns f1 (P,), optional f2 (P,) if k>=2
"""
P = px.shape[0]
device = px.device
dtype = px.dtype
f = [torch.full((P,), float("inf"), device=device, dtype=dtype) for _ in range(k)]
for pi in range(0, P, chunk_px):
pchunk = px[pi:pi+chunk_px] # (Pc,2)
# current bests for this slice
best = [torch.full((pchunk.shape[0],), float("inf"), device=device, dtype=dtype) for _ in range(k)]
for i in range(0, pts.shape[0], chunk_pts):
chunk = pts[i:i+chunk_pts] # (C,2)
# distances squared
d2 = (pchunk[:, None, 0] - chunk[None, :, 0])**2 + (pchunk[:, None, 1] - chunk[None, :, 1])**2
d = torch.sqrt(d2 + 1e-12)
# merge with current bests
merged = torch.cat([d] + [b.view(-1, 1) for b in best], dim=1)
vals, _ = torch.topk(merged, k=k, dim=1, largest=False)
for j in range(k):
best[j] = vals[:, j]
# write back
for j in range(k):
f[j][pi:pi+best[j].shape[0]] = best[j]
return tuple(f)
def poisson_blue_mask_2d(h: int, w: int, radius_px: float, softness: float, seed: int, device, dtype):
"""
Generate a Poisson-disk point pattern, then map distance-to-nearest into [0,1].
softness controls how distance maps to mask: m = 1 - exp(-D / softness).
"""
g = _rand_generator(seed, device)
# Bridson dart throwing with grid accel
r = max(1.0, float(radius_px))
cell = r / math.sqrt(2.0)
gw, gh = int(math.ceil(w / cell)), int(math.ceil(h / cell))
grid = -torch.ones((gh, gw), device=device, dtype=torch.int32)
points = []
def grid_coords(x, y):
return int(y // cell), int(x // cell)
def fits(x, y):
gi, gj = grid_coords(x, y)
i0, i1 = max(0, gi - 2), min(gh - 1, gi + 2)
j0, j1 = max(0, gj - 2), min(gw - 1, gj + 2)
for ii in range(i0, i1 + 1):
for jj in range(j0, j1 + 1):
idx = int(grid[ii, jj].item())
if idx >= 0:
px, py = points[idx]
if (px - x) ** 2 + (py - y) ** 2 < r * r:
return False
return True
# initial point
x0 = torch.rand((), generator=g, device=device).item() * w
y0 = torch.rand((), generator=g, device=device).item() * h
points.append((x0, y0))
gi, gj = grid_coords(x0, y0)
grid[gi, gj] = 0
active = [0]
k = 30
max_points = int(4.0 * (h * w) / (r * r + 1e-6)) # heuristic cap
while active and len(points) < max_points:
ai = int(active[torch.randint(0, len(active), (1,), generator=g, device=device).item()])
cx, cy = points[ai]
placed = False
for _ in range(k):
rad = r * (1.0 + torch.rand((), generator=g, device=device).item())
ang = 2 * math.pi * torch.rand((), generator=g, device=device).item()
nx = cx + rad * math.cos(ang)
ny = cy + rad * math.sin(ang)
if 0 <= nx < w and 0 <= ny < h and fits(nx, ny):
points.append((nx, ny))
gi, gj = grid_coords(nx, ny)
grid[gi, gj] = len(points) - 1
active.append(len(points) - 1)
placed = True
break
if not placed:
active.remove(ai)
if len(points) == 0:
return torch.zeros((h, w), device=device, dtype=dtype)
pts = torch.tensor(points, device=device, dtype=dtype)
yy, xx = torch.meshgrid(torch.arange(h, device=device, dtype=dtype),
torch.arange(w, device=device, dtype=dtype), indexing="ij")
px = torch.stack([xx.reshape(-1), yy.reshape(-1)], dim=1)
(f1,) = _chunked_pairwise_min_dists(px, pts, k=1)
D = f1.reshape(h, w)
m = 1.0 - torch.exp(-D / max(1e-6, float(softness)))
m = (m - m.min()) / (m.max() - m.min() + 1e-12)
return m
def worley_edges_2d(h: int, w: int, points_per_kpx: float, metric: str, edge_sharpness: float, seed: int, device, dtype):
"""
Worley cellular edges via F1/F2 distances. Edge strength ~ 1 - (F2-F1)/norm.
metric: 'L2' or 'L1'.
"""
g = _rand_generator(seed, device)
npts = max(1, int(points_per_kpx * (h * w) / 1000.0))
pts = torch.stack([
torch.rand((npts,), generator=g, device=device, dtype=dtype) * w,
torch.rand((npts,), generator=g, device=device, dtype=dtype) * h,
], dim=1)
yy, xx = torch.meshgrid(torch.arange(h, device=device, dtype=dtype),
torch.arange(w, device=device, dtype=dtype), indexing="ij")
px = torch.stack([xx.reshape(-1), yy.reshape(-1)], dim=1)
# Distance function
def dist_fn(a, b):
if metric.upper() == "L1":
d = torch.abs(a[:, None, :] - b[None, :, :]).sum(dim=2)
else:
d = torch.sqrt(((a[:, None, :] - b[None, :, :]) ** 2).sum(dim=2) + 1e-12)
return d
# chunked keep two smallest distances per pixel
P = px.shape[0]
f1 = torch.full((P,), float("inf"), device=device, dtype=dtype)
f2 = torch.full((P,), float("inf"), device=device, dtype=dtype)
chunk_pts = 256
chunk_px = 65536
for pi in range(0, P, chunk_px):
pchunk = px[pi:pi+chunk_px]
bf1 = torch.full((pchunk.shape[0],), float("inf"), device=device, dtype=dtype)
bf2 = torch.full((pchunk.shape[0],), float("inf"), device=device, dtype=dtype)
for i in range(0, pts.shape[0], chunk_pts):
chunk = pts[i:i+chunk_pts]
d = dist_fn(pchunk, chunk)
merged = torch.cat([d, bf1.view(-1, 1), bf2.view(-1, 1)], dim=1)
vals, _ = torch.topk(merged, k=2, dim=1, largest=False)
bf1, bf2 = vals[:, 0], vals[:, 1]
f1[pi:pi+bf1.shape[0]] = bf1
f2[pi:pi+bf2.shape[0]] = bf2
F1 = f1.reshape(h, w)
F2 = f2.reshape(h, w)
# Edge measure: small gap between F2 and F1 -> on boundaries
gap = (F2 - F1)
gap = gap / (gap.max() + 1e-12)
edges = 1.0 - gap
# sharpen
s = max(0.1, float(edge_sharpness))
edges = torch.clamp(edges, 0.0, 1.0) ** s
edges = (edges - edges.min()) / (edges.max() - edges.min() + 1e-12)
return edges
def tile_oriented_lines_2d(h: int, w: int, tile_size: int, freq_cyc_px: float, jitter: float, seed: int, device, dtype):
"""
Randomized oriented sinusoid per tile, stitched together; normalized to [0,1].
"""
s = max(2, int(tile_size))
g = _rand_generator(seed, device)
yy, xx = torch.meshgrid(torch.arange(h, device=device, dtype=dtype),
torch.arange(w, device=device, dtype=dtype), indexing="ij")
out = torch.zeros((h, w), device=device, dtype=dtype)
two_pi = 2.0 * math.pi
f = float(freq_cyc_px)
for y0 in range(0, h, s):
for x0 in range(0, w, s):
y1 = min(h, y0 + s)
x1 = min(w, x0 + s)
th = torch.rand((), generator=g, device=device, dtype=dtype).item() * two_pi
ph = (torch.rand((), generator=g, device=device, dtype=dtype).item() - 0.5) * two_pi * float(jitter)
sl = slice(y0, y1)
sc = slice(x0, x1)
proj = (xx[sl, sc] - x0) * math.cos(th) + (yy[sl, sc] - y0) * math.sin(th)
gr = torch.sin(two_pi * f * proj + ph)
out[sl, sc] = gr
out = (out - out.min()) / (out.max() - out.min() + 1e-12)
return out
def dot_screen_jitter_2d(h: int, w: int, cell_size: int, jitter_px: float, fill_ratio: float, seed: int, device, dtype):
"""
Halftone dot lattice with per-cell jitter of center and radius.
fill_ratio ~ area fraction per cell (0..1).
"""
cs = max(2, int(cell_size))
g = _rand_generator(seed, device)
yy, xx = torch.meshgrid(torch.arange(h, device=device, dtype=dtype),
torch.arange(w, device=device, dtype=dtype), indexing="ij")
mask = torch.zeros((h, w), device=device, dtype=dtype)
base_r = 0.5 * math.sqrt(fill_ratio) * cs
for y0 in range(0, h, cs):
for x0 in range(0, w, cs):
y1 = min(h, y0 + cs)
x1 = min(w, x0 + cs)
jx = (torch.rand((), generator=g, device=device, dtype=dtype).item() - 0.5) * 2.0 * float(jitter_px)
jy = (torch.rand((), generator=g, device=device, dtype=dtype).item() - 0.5) * 2.0 * float(jitter_px)
cx = x0 + cs * 0.5 + jx
cy = y0 + cs * 0.5 + jy
rj = base_r * (0.8 + 0.4 * torch.rand((), generator=g, device=device, dtype=dtype).item())
sl = slice(y0, y1)
sc = slice(x0, x1)
d2 = (xx[sl, sc] - cx) ** 2 + (yy[sl, sc] - cy) ** 2
mask[sl, sc] = torch.where(d2 <= rj * rj, torch.tensor(1.0, device=device, dtype=dtype), torch.tensor(0.0, device=device, dtype=dtype))
# Slight blur to smooth hard edges
mask = gaussian_blur(mask.unsqueeze(0).unsqueeze(0), ksize=3, sigma=0.6).squeeze(0).squeeze(0)
mask = (mask - mask.min()) / (mask.max() - mask.min() + 1e-12)
return mask
def _grid(h: int, w: int, device, dtype):
yy = torch.arange(h, device=device, dtype=dtype).view(h, 1).expand(h, w)
xx = torch.arange(w, device=device, dtype=dtype).view(1, w).expand(h, w)
return xx, yy
def cross_hatch_2d(
h: int,
w: int,
freq_cyc_px: float,
angles_deg: tuple,
square: bool,
phase_jitter: float,
supersample: int,
seed: int,
device,
dtype,
):
"""
Generate cross-hatch gratings at given frequency (cycles/pixel) and angles.
Optionally square-wave, phase jitter, and supersample then downsample.
Returns normalized [0,1].
"""
s = max(1, int(supersample))
H, W = h * s, w * s
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
xx, yy = _grid(H, W, device, dtype)
two_pi = 2.0 * math.pi
f = float(freq_cyc_px)
# Allow aliasing if f > 0.5 (user-controlled)
acc = torch.zeros((H, W), device=device, dtype=dtype)
ad = angles_deg if isinstance(angles_deg, (list, tuple)) else [0.0, 90.0]
for a in ad:
th = math.radians(float(a))
# Phase jitter in [0, 2π*phase_jitter)
phi = 0.0
if phase_jitter > 0.0:
phi = torch.rand((), generator=rng, device=device, dtype=dtype).item() * two_pi * float(phase_jitter)
proj = xx * math.cos(th) + yy * math.sin(th)
gr = torch.sin(two_pi * f * proj + phi)
if square:
gr = torch.sign(gr)
acc = acc + gr
# Normalize to [0,1]
if len(ad) > 0:
acc = acc / float(len(ad))
acc = (acc - acc.min()) / (acc.max() - acc.min() + 1e-12)
if s > 1:
acc = acc.unsqueeze(0).unsqueeze(0)
# Light blur before downsample to reduce aliasing
acc = gaussian_blur(acc, ksize=3, sigma=0.6)
acc = F.interpolate(acc, size=(h, w), mode="area")
acc = acc.squeeze(0).squeeze(0)
acc = (acc - acc.min()) / (acc.max() - acc.min() + 1e-12)
return acc
def highpass_white_2d(h: int, w: int, cutoff_frac: float, order: int, seed: int, device, dtype):
"""
White noise shaped by Butterworth high-pass filter.
cutoff_frac: 0..1 fraction of Nyquist radius.
order: filter order (>=1).
"""
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
base = torch.randn((h, w), generator=rng, device=device, dtype=dtype)
F = torch.fft.fft2(base)
r = _radial_frequency_grid(h, w, device, dtype)
r = torch.clamp(r, min=1e-6)
rmax = r.max().clamp(min=1e-6)
rc = torch.as_tensor(cutoff_frac, device=device, dtype=dtype).clamp(1e-6, 1.0) * rmax
n = max(1, int(order))
# Butterworth high-pass magnitude response
H = 1.0 / (1.0 + torch.pow(rc / r, 2 * n))
H = H.to(F.dtype)
F_hp = F * H
x = torch.fft.ifft2(F_hp).real
x = (x - x.min()) / (x.max() - x.min() + 1e-12)
return x
def ring_noise_2d(h: int, w: int, center_frac: float, bandwidth_frac: float, seed: int, device, dtype):
"""
Narrow annulus band-pass white noise around center_frac of Nyquist radius.
"""
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
base = torch.randn((h, w), generator=rng, device=device, dtype=dtype)
F = torch.fft.fft2(base)
r = _radial_frequency_grid(h, w, device, dtype)
rmax = r.max().clamp(min=1e-6)
c = torch.as_tensor(center_frac, device=device, dtype=dtype).clamp(0.0, 1.0) * rmax
bw = torch.as_tensor(bandwidth_frac, device=device, dtype=dtype).clamp(1e-6, 1.0) * rmax
# Gaussian ring
sigma = bw
mask = torch.exp(-0.5 * ((r - c) / sigma) ** 2)
F_ring = F * mask
x = torch.fft.ifft2(F_ring).real
x = (x - x.min()) / (x.max() - x.min() + 1e-12)
return x
def pink_noise_2d(h: int, w: int, seed: int, device, dtype):
return spectral_noise(h, w, beta=-1.0, seed=seed, device=device, dtype=dtype)
def brown_noise_2d(h: int, w: int, seed: int, device, dtype):
# Also known as red noise
return spectral_noise(h, w, beta=-2.0, seed=seed, device=device, dtype=dtype)
def blue_noise_2d(h: int, w: int, seed: int, device, dtype):
return spectral_noise(h, w, beta=+1.0, seed=seed, device=device, dtype=dtype)
def violet_noise_2d(h: int, w: int, seed: int, device, dtype):
# Also called purple noise
return spectral_noise(h, w, beta=+2.0, seed=seed, device=device, dtype=dtype)
def velvet_noise(h: int, w: int, tap_count: int, seed: int, device, dtype):
"""
Generate 2D velvet noise: sparse random ±1 impulses, returned in [0,1].
tap_count: number of impulses to place. Values are clipped to [1, h*w].
"""
tap_count = int(max(1, min(tap_count, h * w)))
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
v = torch.zeros((h, w), device=device, dtype=dtype)
perm = torch.randperm(h * w, generator=rng, device=device)[:tap_count]
signs = (torch.randint(0, 2, (tap_count,), generator=rng, device=device) * 2 - 1).to(dtype)
flat = v.view(-1)
flat[perm] = signs
v = (v + 1.0) * 0.5
return v
def green_noise_2d(h: int, w: int, seed: int, device, dtype, center_frac: float = 0.35, bandwidth_frac: float = 0.15):
"""
Generate green noise as a mid-frequency band-pass shaped white noise.
center_frac, bandwidth_frac are relative to max radial frequency (rmax). Typical values:
center_frac ~ 0.3-0.4, bandwidth_frac ~ 0.1-0.2
"""
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
base = torch.randn((h, w), generator=rng, device=device, dtype=dtype)
F = torch.fft.fft2(base)
r = _radial_frequency_grid(h, w, device, dtype)
rmax = r.max().clamp(min=1e-6)
c = torch.as_tensor(center_frac, device=device, dtype=dtype).clamp(0.0, 1.0) * rmax
bw = torch.as_tensor(bandwidth_frac, device=device, dtype=dtype).clamp(1e-6, 1.0) * rmax
sigma = bw
wmask = torch.exp(-0.5 * ((r - c) / sigma) ** 2)
F_bp = F * wmask
x = torch.fft.ifft2(F_bp).real
x = (x - x.min()) / (x.max() - x.min() + 1e-12)
return x
def black_noise_2d(h: int, w: int, seed: int, device, dtype, bins: int = 2048):
"""
Generate black noise as sparse narrowband content: keep a small number of
frequency bins (in rFFT domain), zero elsewhere, then inverse transform.
"""
rng = torch.Generator(device=device)
if seed is not None:
rng.manual_seed(seed)
spec_shape = (h, w // 2 + 1)
total_bins = spec_shape[0] * spec_shape[1]
k = int(max(1, min(bins, total_bins - 1)))
spec = torch.zeros(spec_shape, device=device, dtype=torch.complex64)
idx = torch.randperm(total_bins, generator=rng, device=device)
idx = idx[idx != 0]
idx = idx[:k]
real = torch.randn((k,), generator=rng, device=device)
imag = torch.randn((k,), generator=rng, device=device)
vals = torch.complex(real, imag)
flat = spec.view(-1)
flat[idx] = vals
spec = flat.view(spec_shape)
x = torch.fft.irfft2(spec, s=(h, w))
x = x.to(dtype)
x = (x - x.min()) / (x.max() - x.min() + 1e-12)
return x
def get_cfg_for_step(cfg_values, step_index, total_steps):
"""
Get the CFG value for a specific step from a list of CFG values.
If step_index exceeds the list length, repeat the last value.
Args:
cfg_values: List of CFG float values or single float value
step_index: Current step index (0-based)
total_steps: Total number of steps
Returns:
float: CFG value for the current step
"""
if isinstance(cfg_values, (int, float)):
return float(cfg_values)
if isinstance(cfg_values, (list, tuple)) and len(cfg_values) > 0:
if step_index < len(cfg_values):
return float(cfg_values[step_index])
else:
return float(cfg_values[-1])
return 4.5