yum/3ner

A toon shader for Unity's BIRP.

git clone https://git.yummers.dev/yum/3ner

yumMake gaussanize sigma match burley paper57aa53c

master
20.3 KiB579 linesraw
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()