yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
57aa53c
master
1#!/usr/bin/env -S uv run --script 2# /// script 3# requires-python = ">=3.10" 4# dependencies = [ 5# "numpy", 6# "scipy", 7# "pillow", 8# "openexr", 9# "imath", 10# "matplotlib" 11# ] 12# /// 13 14""" 15Gaussianization for Histogram-Preserving Blending 16Based on Burley's "On Histogram-preserving Blending for Randomized Texture Tiling" (2019) 17 18This implementation uses per-channel 1D histogram transformation with: 19- Truncated Gaussian distribution (avoiding values outside [0,1]) 20- Soft-clipping contrast operator 21- Fast 1D LUT generation from input histogram 22""" 23 24import argparse 25import sys 26from pathlib import Path 27import numpy as np 28from scipy import special 29from PIL import Image 30import OpenEXR 31import Imath 32 33 34def load_image(image_path: Path) -> np.ndarray: 35 """Load a PNG or EXR image and return as float64 array (H, W, 3) in [0,1].""" 36 suffix = image_path.suffix.lower() 37 if suffix == '.exr': 38 exr = OpenEXR.InputFile(str(image_path)) 39 header = exr.header() 40 dw = header['dataWindow'] 41 w = dw.max.x - dw.min.x + 1 42 h = dw.max.y - dw.min.y + 1 43 channels = [] 44 for ch in ('R', 'G', 'B'): 45 raw = exr.channel(ch, Imath.PixelType(Imath.PixelType.HALF)) 46 channels.append(np.frombuffer(raw, dtype=np.float16).reshape(h, w)) 47 return np.stack(channels, axis=-1).astype(np.float64) 48 else: 49 return np.array(Image.open(image_path).convert("RGB")).astype(np.float64) / 255.0 50 51 52def save_image(image: np.ndarray, output_path: Path): 53 """Save an RGB float image as EXR or PNG depending on the file suffix.""" 54 suffix = output_path.suffix.lower() 55 if suffix == ".exr": 56 image_to_save = image.astype(np.float16) 57 h, w, _ = image_to_save.shape 58 header = OpenEXR.Header(w, h) 59 half_chan = Imath.Channel(Imath.PixelType(Imath.PixelType.HALF)) 60 header['channels'] = {'R': half_chan, 'G': half_chan, 'B': half_chan} 61 out = OpenEXR.OutputFile(str(output_path), header) 62 out.writePixels({ 63 'R': image_to_save[:, :, 0].tobytes(), 64 'G': image_to_save[:, :, 1].tobytes(), 65 'B': image_to_save[:, :, 2].tobytes(), 66 }) 67 out.close() 68 elif suffix == ".png": 69 clipped = np.clip(image, 0.0, 1.0) 70 Image.fromarray(np.round(clipped * 255.0).astype(np.uint8), mode="RGB").save(output_path) 71 else: 72 raise ValueError(f"Unsupported output format '{output_path.suffix}'. Use .exr or .png.") 73 74 75def rgb_to_ycrcb(image: np.ndarray) -> np.ndarray: 76 """Convert RGB in [0, 1] to full-range YCrCb in [0, 1].""" 77 rgb = np.clip(image, 0.0, 1.0) 78 r = rgb[:, :, 0] 79 g = rgb[:, :, 1] 80 b = rgb[:, :, 2] 81 82 y = 0.299 * r + 0.587 * g + 0.114 * b 83 cb = 0.5 + (b - y) * 0.564 84 cr = 0.5 + (r - y) * 0.713 85 return np.stack((y, cr, cb), axis=-1) 86 87 88def ycrcb_to_rgb(image: np.ndarray) -> np.ndarray: 89 """Convert full-range YCrCb in [0, 1] back to RGB in [0, 1].""" 90 ycrcb = np.asarray(image, dtype=np.float64) 91 y = ycrcb[:, :, 0] 92 cr = ycrcb[:, :, 1] - 0.5 93 cb = ycrcb[:, :, 2] - 0.5 94 95 r = y + 1.402 * cr 96 g = y - 0.714136 * cr - 0.344136 * cb 97 b = y + 1.772 * cb 98 return np.clip(np.stack((r, g, b), axis=-1), 0.0, 1.0) 99 100 101def subtract_low_frequencies(channel: np.ndarray, cutoff_cycles: float = 8.0) -> np.ndarray: 102 """Subtract a smooth periodic low-frequency component from a scalar image.""" 103 if cutoff_cycles <= 0.0: 104 raise ValueError(f"cutoff_cycles must be > 0, got {cutoff_cycles}") 105 106 channel = np.asarray(channel, dtype=np.float64) 107 height, width = channel.shape 108 109 fy = np.fft.fftfreq(height) 110 fx = np.fft.fftfreq(width) 111 radius = np.sqrt((fy[:, np.newaxis] * height) ** 2 + (fx[np.newaxis, :] * width) ** 2) 112 113 low_mask = np.exp(-((radius / cutoff_cycles) ** 2)) 114 low_pass = np.fft.ifft2(np.fft.fft2(channel) * low_mask).real 115 116 # Remove only the spatial trend and keep the original average luminance. 117 return channel - low_pass + float(np.mean(low_pass)) 118 119 120def homogenize_luminance(image: np.ndarray, cutoff_cycles: float = 8.0) -> np.ndarray: 121 """Reduce very-low-frequency luminance variation while preserving chroma.""" 122 ycrcb = rgb_to_ycrcb(image) 123 ycrcb[:, :, 0] = subtract_low_frequencies(ycrcb[:, :, 0], cutoff_cycles=cutoff_cycles) 124 return ycrcb_to_rgb(ycrcb) 125 126 127class TruncatedGaussian: 128 """Truncated Gaussian distribution for histogram transformation.""" 129 130 def __init__(self, sigma: float = 1.0 / np.sqrt(2.0)): 131 """ 132 Initialize truncated Gaussian centered at 0.5 with given sigma. 133 Default sigma = 1/sqrt(2), matching Burley's paper. 134 """ 135 self.sigma = sigma 136 self.mu = 0.5 137 138 # Burley's C(sigma) is the reciprocal normalization factor required 139 # after truncating the Gaussian to the [0, 1] interval. 140 self.C = 1.0 / special.erf(1.0 / (2.0 * np.sqrt(2.0) * sigma)) 141 142 def inverse_cdf(self, u: np.ndarray) -> np.ndarray: 143 """ 144 Inverse CDF of truncated Gaussian distribution. 145 Maps uniform values in [0,1] to truncated Gaussian in [0,1]. 146 147 Equation (3) from Burley's paper: 148 CDF^-1_[G](u; σ) = 1/2 + sqrt(2)σ * erfinv((2u - 1) / C(σ)) 149 """ 150 u = np.clip(u, 0.0, 1.0) 151 result = 0.5 + np.sqrt(2.0) * self.sigma * special.erfinv((2.0 * u - 1.0) / self.C) 152 return np.clip(result, 0.0, 1.0) 153 154 def cdf(self, x: np.ndarray) -> np.ndarray: 155 """ 156 CDF of truncated Gaussian distribution. 157 Maps truncated Gaussian values to uniform [0,1]. 158 """ 159 x = np.clip(x, 0.0, 1.0) 160 result = 0.5 * (1.0 + self.C * special.erf((x - 0.5) / (np.sqrt(2.0) * self.sigma))) 161 return np.clip(result, 0.0, 1.0) 162 163 164def _cdf_bin_edges(histogram: np.ndarray) -> np.ndarray: 165 """Return normalized CDF samples on histogram bin edges.""" 166 histogram = np.asarray(histogram, dtype=np.float64) 167 total = float(histogram.sum()) 168 if total <= 0.0: 169 return np.linspace(0.0, 1.0, len(histogram) + 1) 170 return np.concatenate(([0.0], np.cumsum(histogram / total, dtype=np.float64))) 171 172 173def _occupied_bin_mid_quantiles(histogram: np.ndarray) -> tuple[np.ndarray, np.ndarray]: 174 """Return source-value centers and CDF midpoints for occupied bins.""" 175 histogram = np.asarray(histogram, dtype=np.float64) 176 n_bins = len(histogram) 177 cdf_edges = _cdf_bin_edges(histogram) 178 bin_mass = cdf_edges[1:] - cdf_edges[:-1] 179 occupied = bin_mass > 0.0 180 181 if not np.any(occupied): 182 centers = (np.arange(n_bins, dtype=np.float64) + 0.5) / n_bins 183 return centers, centers 184 185 centers = (np.arange(n_bins, dtype=np.float64) + 0.5) / n_bins 186 mid_quantiles = cdf_edges[:-1] + 0.5 * bin_mass 187 return centers[occupied], mid_quantiles[occupied] 188 189 190def build_gaussianization_lut(histogram: np.ndarray, lut_size: int = 4096) -> np.ndarray: 191 """ 192 Build 1D LUT for Gaussianizing a channel based on its histogram. 193 194 Algorithm 1 from Burley's paper: 195 1. Compute CDF from histogram 196 2. Transform through inverse CDF of truncated Gaussian 197 """ 198 gaussian = TruncatedGaussian() 199 value_centers, mid_quantiles = _occupied_bin_mid_quantiles(histogram) 200 mapped_centers = gaussian.inverse_cdf(mid_quantiles) 201 202 sample_positions = np.linspace(0.0, 1.0, lut_size) 203 return np.interp( 204 sample_positions, 205 value_centers, 206 mapped_centers, 207 left=mapped_centers[0], 208 right=mapped_centers[-1], 209 ) 210 211 212def apply_lut(image: np.ndarray, lut: np.ndarray) -> np.ndarray: 213 """Apply a 1D LUT to an image channel using linear interpolation.""" 214 lut_size = len(lut) 215 coords = np.clip(image, 0.0, 1.0) * (lut_size - 1) 216 indices0 = np.floor(coords).astype(np.int32) 217 indices1 = np.minimum(indices0 + 1, lut_size - 1) 218 alpha = coords - indices0 219 return (1.0 - alpha) * lut[indices0] + alpha * lut[indices1] 220 221 222def _deterministic_noise(shape: tuple[int, int], channel: int) -> np.ndarray: 223 """Generate stable per-pixel noise in [0, 1) from pixel coordinates.""" 224 height, width = shape 225 yy, xx = np.indices((height, width), dtype=np.uint32) 226 state = xx * np.uint32(0x1F123BB5) ^ yy * np.uint32(0x159A55E5) ^ np.uint32(channel + 1) * np.uint32(0x2C1B3C6D) 227 state ^= state >> np.uint32(16) 228 state *= np.uint32(0x7FEB352D) 229 state ^= state >> np.uint32(15) 230 state *= np.uint32(0x846CA68B) 231 state ^= state >> np.uint32(16) 232 return state.astype(np.float64) / float(np.iinfo(np.uint32).max) 233 234 235def dither_channel(channel: np.ndarray, quantization_step: float, channel_index: int) -> np.ndarray: 236 """Spread repeated quantized values across their source bucket deterministically.""" 237 if quantization_step <= 0.0: 238 return channel 239 noise = _deterministic_noise(channel.shape, channel_index) - 0.5 240 return np.clip(channel + noise * quantization_step, 0.0, 1.0) 241 242 243def _soft_clipping_lower_half(x_hat: np.ndarray, W_hat: float) -> np.ndarray: 244 """Evaluate Burley's Eq. 4 on the lower half of the domain.""" 245 linear_start = (2.0 - W_hat) / 4.0 246 linear = (x_hat - 0.5) / W_hat + 0.5 247 248 if W_hat >= (2.0 / 3.0): 249 t = x_hat / (2.0 - W_hat) 250 quadratic = 8.0 * (1.0 / W_hat - 1.0) * (t ** 2) + (3.0 - 2.0 / W_hat) * t 251 return np.where(x_hat >= linear_start, linear, quadratic) 252 253 quadratic_start = (2.0 - 3.0 * W_hat) / 4.0 254 quadratic = ((x_hat - quadratic_start) / W_hat) ** 2 255 return np.where( 256 x_hat >= linear_start, 257 linear, 258 np.where(x_hat >= quadratic_start, quadratic, 0.0), 259 ) 260 261 262def soft_clipping_contrast(x_hat: np.ndarray, W_hat: float) -> np.ndarray: 263 """ 264 Soft-clipping contrast operator S*_[G] from Equation (4) in Burley's paper. 265 266 This is a piecewise function that: 267 - Is linear in the middle half of the range 268 - Blends smoothly to 0 or 1 using quadratic segments at the ends 269 """ 270 if not (0.0 < W_hat <= 1.0): 271 raise ValueError(f"W_hat must be in (0, 1], got {W_hat}") 272 273 x_hat = np.clip(x_hat, 0.0, 1.0) 274 lower_input = np.where(x_hat <= 0.5, x_hat, 1.0 - x_hat) 275 lower_result = _soft_clipping_lower_half(lower_input, W_hat) 276 result = np.where(x_hat <= 0.5, lower_result, 1.0 - lower_result) 277 return np.clip(result, 0.0, 1.0) 278 279 280def gaussianize_texture( 281 image: np.ndarray, 282 verbose: bool = True, 283 quantization_step: float = 0.0, 284) -> tuple[np.ndarray, list]: 285 """ 286 Gaussianize a texture using per-channel 1D histogram transformation. 287 288 Returns: 289 - Gaussianized image 290 - List of inverse LUTs (one per channel) for restoration 291 """ 292 _, _, c = image.shape 293 if c != 3: 294 raise ValueError(f"Expected RGB image with 3 channels, got {c}") 295 296 # Process each channel independently 297 gaussianized = np.zeros_like(image) 298 inverse_luts = [] 299 300 for ch in range(3): 301 if verbose: 302 channel_name = ['R', 'G', 'B'][ch] 303 print(f"Processing channel {channel_name}...") 304 305 # Break ties inside quantized source buckets before building the transport. 306 channel = dither_channel(image[:, :, ch], quantization_step, ch) 307 308 # Compute histogram (using 4096 bins for better precision) 309 hist, _ = np.histogram(channel.flatten(), bins=4096, range=(0.0, 1.0)) 310 311 # Build Gaussianization LUT 312 lut = build_gaussianization_lut(hist, lut_size=4096) 313 314 # Apply LUT to channel 315 gaussianized[:, :, ch] = apply_lut(channel, lut) 316 317 # Build inverse LUT for later restoration 318 inverse_lut = build_inverse_lut(hist, lut_size=4096) 319 inverse_luts.append(inverse_lut) 320 321 return gaussianized, inverse_luts 322 323 324def build_inverse_lut(original_histogram: np.ndarray, lut_size: int = 4096) -> np.ndarray: 325 """ 326 Build inverse LUT to restore original histogram from Gaussianized values. 327 This maps from Gaussian distribution back to original distribution. 328 """ 329 gaussian = TruncatedGaussian() 330 value_centers, mid_quantiles = _occupied_bin_mid_quantiles(original_histogram) 331 332 gaussian_values = np.linspace(0.0, 1.0, lut_size) 333 uniform_values = gaussian.cdf(gaussian_values) 334 return np.interp( 335 uniform_values, 336 mid_quantiles, 337 value_centers, 338 left=value_centers[0], 339 right=value_centers[-1], 340 ) 341 342 343def histogram_preserving_blend( 344 textures: list[np.ndarray], 345 weights: np.ndarray, 346 inverse_luts: list[np.ndarray] | list[list[np.ndarray]], 347 gamma: float = 1.0 348) -> np.ndarray: 349 """ 350 Perform histogram-preserving blend of multiple Gaussianized textures. 351 352 Algorithm 2 from Burley's paper: 353 1. Optionally exponentiate weights 354 2. Linear blend 355 3. Compute variance scale factor W_hat 356 4. Apply soft-clipping contrast operator 357 5. Apply inverse LUTs to restore original histogram 358 359 Args: 360 textures: List of Gaussianized textures 361 weights: Blending weights (must sum to 1) 362 inverse_luts: Shared inverse LUTs for the source texture, or repeated copies 363 of the same LUT set for each texture. 364 gamma: Exponent for weight adjustment (Eq. 5) 365 """ 366 n_textures = len(textures) 367 if len(weights) != n_textures: 368 raise ValueError(f"Number of weights ({len(weights)}) must match number of textures ({n_textures})") 369 370 if len(inverse_luts) == 3 and all(np.asarray(lut).ndim == 1 for lut in inverse_luts): 371 shared_inverse_luts = inverse_luts 372 else: 373 if len(inverse_luts) != n_textures: 374 raise ValueError( 375 "inverse_luts must be either one shared RGB LUT set or one repeated set per texture" 376 ) 377 shared_inverse_luts = inverse_luts[0] 378 for lut_set in inverse_luts[1:]: 379 if any(not np.array_equal(ref, cur) for ref, cur in zip(shared_inverse_luts, lut_set)): 380 raise ValueError( 381 "Burley's per-channel method assumes all blended tiles share the same histogram LUTs" 382 ) 383 384 # Normalize weights 385 weights = np.array(weights, dtype=np.float64) / np.sum(weights) 386 387 # Apply weight exponentiation if gamma != 1 (Equation 5) 388 if gamma != 1.0: 389 weights_exp = np.power(weights, gamma) 390 weights = weights_exp / np.sum(weights_exp) 391 392 # Linear blend (Equation 1) 393 blended = np.zeros_like(textures[0]) 394 for tex, w in zip(textures, weights): 395 blended += w * tex 396 397 # Compute variance scale factor (Equation 2) 398 W_hat = np.sqrt(np.sum(weights ** 2)) 399 400 # Apply contrast restoration per channel 401 result = np.zeros_like(blended) 402 for ch in range(3): 403 # Apply soft-clipping contrast operator (Equation 4) 404 result[:, :, ch] = soft_clipping_contrast(blended[:, :, ch], W_hat) 405 406 # Apply inverse LUT to restore the shared source histogram. 407 result[:, :, ch] = apply_lut(result[:, :, ch], shared_inverse_luts[ch]) 408 409 return result 410 411 412def verify_histogram(image_path: Path, output_path: Path): 413 """Generate the original image with an RGB histogram chart beneath it.""" 414 import matplotlib 415 matplotlib.use('Agg') 416 import matplotlib.pyplot as plt 417 from matplotlib.ticker import MultipleLocator 418 419 img = load_image(image_path) 420 h, w, _ = img.shape 421 quantization_step = 0.0 if image_path.suffix.lower() == ".exr" else (1.0 / 255.0) 422 423 # Chart is the full image width, with height proportional to the image 424 chart_height_ratio = 0.25 425 total_height_ratio = 1.0 + chart_height_ratio 426 fig_w = max(w, 2048) / 180.0 427 fig_h = fig_w * total_height_ratio * (h / w) 428 429 fig = plt.figure(figsize=(fig_w, fig_h), facecolor='#bcbcbc') 430 431 # Original image on top 432 img_ax = fig.add_axes((0.0, chart_height_ratio / total_height_ratio, 1.0, 1.0 / total_height_ratio)) 433 img_ax.imshow(np.clip(img, 0.0, 1.0)) 434 img_ax.set_axis_off() 435 436 # Histogram chart on bottom 437 left_margin = 0.06 438 right_margin = 0.02 439 bottom_margin = 0.04 440 chart_top = chart_height_ratio / total_height_ratio 441 plot_ax = fig.add_axes((left_margin, bottom_margin, 1.0 - left_margin - right_margin, chart_top - bottom_margin)) 442 plot_ax.set_facecolor('#bcbcbc') 443 for spine in plot_ax.spines.values(): 444 spine.set_visible(False) 445 446 kernel = np.array([1.0, 2.0, 3.0, 2.0, 1.0], dtype=np.float64) 447 kernel /= kernel.sum() 448 curve_max = 0.0 449 colors = ('#ff1a1a', '#00aa22', '#003cff') 450 451 for channel, color in enumerate(colors): 452 data = dither_channel(img[:, :, channel], quantization_step, channel) 453 hist, edges = np.histogram(data.ravel(), bins=512, range=(0.0, 1.0), density=True) 454 hist = np.convolve(hist, kernel, mode='same') 455 centers = 0.5 * (edges[:-1] + edges[1:]) 456 curve_max = max(curve_max, float(hist.max())) 457 plot_ax.plot(centers, hist, color=color, linewidth=0.9, antialiased=True) 458 459 plot_ax.set_xlim(0.0, 1.0) 460 plot_ax.set_ylim(0.0, curve_max * 1.18 if curve_max > 0.0 else 1.0) 461 plot_ax.xaxis.set_major_locator(MultipleLocator(0.1)) 462 plot_ax.tick_params(axis='x', labelsize=5, length=2, pad=1) 463 plot_ax.set_yticks([]) 464 fig.savefig(output_path, dpi=180, facecolor=fig.get_facecolor(), edgecolor='none') 465 plt.close(fig) 466 print(f"Saved histogram to {output_path}") 467 468 469def save_lut_as_image( 470 luts: list[np.ndarray], 471 output_path: Path, 472 width: int = 2048, 473 height: int = 2048, 474): 475 """Save inverse LUTs as a 2D texture with LUT samples running across columns.""" 476 if width <= 0 or height <= 0: 477 raise ValueError(f"Invalid LUT image size {width}x{height}") 478 479 lut_size = len(luts[0]) 480 if any(len(lut) != lut_size for lut in luts): 481 raise ValueError("All inverse LUT channels must have the same length") 482 483 src_coords = np.linspace(0.0, 1.0, lut_size) 484 dst_coords = np.linspace(0.0, 1.0, width) 485 packed_columns = np.stack( 486 [np.interp(dst_coords, src_coords, lut) for lut in luts], 487 axis=-1, 488 ) 489 490 lut_image = np.broadcast_to(packed_columns[np.newaxis, :, :], (height, width, 3)).copy() 491 save_image(lut_image, output_path) 492 493 494def main(): 495 parser = argparse.ArgumentParser( 496 description="Gaussianize texture using Burley's per-channel histogram-preserving method", 497 formatter_class=argparse.ArgumentDefaultsHelpFormatter 498 ) 499 500 parser.add_argument( 501 "input", 502 type=Path, 503 help="Path to the input texture (PNG or EXR)" 504 ) 505 parser.add_argument( 506 "-o", "--output", 507 type=Path, 508 default=None, 509 help="Output path. Defaults to <input>_gaussianized.exr" 510 ) 511 parser.add_argument( 512 "--inverse-lut", 513 action="store_true", 514 default=True, 515 help="Also save the inverse LUT as <input>_inverse_lut.exr" 516 ) 517 parser.add_argument( 518 "--verify", 519 action="store_true", 520 help="Generate histogram visualization instead of processing" 521 ) 522 parser.add_argument( 523 "-v", "--verbose", 524 action="store_true", 525 help="Print detailed progress information" 526 ) 527 parser.add_argument( 528 "--homo", 529 action="store_true", 530 help="Homogenize luminance before gaussianizing by removing very-low-frequency Y in YCrCb space" 531 ) 532 533 args = parser.parse_args() 534 535 if not args.input.exists(): 536 print(f"Error: Input file '{args.input}' does not exist.", file=sys.stderr) 537 sys.exit(1) 538 539 if args.verify: 540 # Generate histogram visualization 541 hist_path = args.input.with_name(args.input.stem + "_histogram.png") 542 verify_histogram(args.input, hist_path) 543 else: 544 # Load input image 545 print(f"Loading {args.input}...") 546 image = load_image(args.input) 547 quantization_step = 0.0 if args.input.suffix.lower() == ".exr" else (1.0 / 255.0) 548 549 if args.homo: 550 print("Homogenizing luminance in YCrCb space...") 551 image = homogenize_luminance(image) 552 553 # Gaussianize the texture 554 print("Applying per-channel Gaussianization...") 555 gaussianized, inverse_luts = gaussianize_texture( 556 image, 557 verbose=args.verbose, 558 quantization_step=quantization_step, 559 ) 560 561 # Determine output path 562 if args.output is None: 563 args.output = args.input.with_name(args.input.stem + "_gaussianized.exr") 564 565 # Save Gaussianized texture 566 print(f"Saving Gaussianized texture to {args.output}...") 567 save_image(gaussianized, args.output) 568 569 # Optionally save inverse LUT 570 if args.inverse_lut: 571 lut_path = args.input.with_name(args.input.stem + "_inverse_lut.exr") 572 print(f"Saving inverse LUT to {lut_path}...") 573 save_lut_as_image(inverse_luts, lut_path) 574 575 print("Done!") 576 577 578if __name__ == "__main__": 579 main()