From 1eaf8b1ff1509c83266ba3474b26cb601062bd15 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TVBJISQ\\Primere" Date: Sun, 29 Mar 2026 11:28:33 +0200 Subject: [PATCH] V 2.0.0 - Rasterix - Posterize --- Nodes/Rasterix.py | 35 +++++ __init__.py | 2 + components/images/histogram.py | 99 ------------ components/images/img_blur.py | 10 +- components/images/img_dithering.py | 42 ----- components/images/img_film_grain.py | 2 - components/images/img_film_rendering.py | 1 - components/images/img_hue_saturation.py | 23 +-- components/images/img_levels_auto.py | 186 ----------------------- components/images/img_levels_compress.py | 87 ----------- components/images/img_posterize.py | 35 +++++ components/images/img_shade_level.py | 47 +----- components/images/img_white_balance.py | 7 - front_end/primere_rasterix.js | 162 ++++++++++++-------- requirements.txt | 4 +- 15 files changed, 177 insertions(+), 565 deletions(-) create mode 100644 components/images/img_posterize.py diff --git a/Nodes/Rasterix.py b/Nodes/Rasterix.py index 0550b75..fe9c118 100644 --- a/Nodes/Rasterix.py +++ b/Nodes/Rasterix.py @@ -19,6 +19,7 @@ from ..components.images import img_lens_effects as img_lens_effects from ..components.images import img_levels_compress as img_levels_compress from ..components.images import img_dithering as img_dithering from ..components.images import histogram as histogram +from ..components.images import img_posterize as img_posterize from ..components import utility from .Dashboard import PrimereModelConceptSelector as PrimereModelConceptSelector import os @@ -124,6 +125,10 @@ class PrimereRasterix: "adaptive_dither_strength": ("BOOLEAN", {"default": False, "label_off": "Keep dither strength", "label_on": "Increase dither strength"}), "error_diffusion": ("BOOLEAN", {"default": False, "label_off": "Error diffusion OFF", "label_on": "Error diffusion ON"}), + "use_posterize": ("BOOLEAN", {"default": False, "label_off": "Ignore posterize", "label_on": "Apply posterize"}), + "shades": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}), + "channels": (["Red", "Green", "Blue"], {"default": "Red"}), + "use_ai_detection_bypasser": ("BOOLEAN", {"default": False, "label_off": "AI detection bypass off", "label_on": "AI detection bypass on"}), "adb_freq_strength": ("FLOAT", {"default": 0.019, "min": 0.0, "max": 0.1, "step": 0.001}), "adb_variance_strength": ("FLOAT", {"default": 0.32, "min": 0.0, "max": 1.0, "step": 0.01}), @@ -228,6 +233,7 @@ class PrimereRasterix: dither_quantization = kwargs.get('dither_quantization', False) adaptive_dither_strength = kwargs.get('adaptive_dither_strength', False) error_diffusion = kwargs.get('error_diffusion', False) + use_posterize = kwargs.get('use_level_endpoints', False) show_histogram = kwargs.get('show_histogram', False) histogram_source = kwargs.get('histogram_source', False) histogram_channel = kwargs.get('histogram_channel', "RGB") @@ -287,6 +293,10 @@ class PrimereRasterix: if dither_quantization or error_diffusion or normalize_midpeaks: pil_img = img_dithering.img_dithering(image=pil_img, dither_quantization=dither_quantization, adaptive_dither_strength=adaptive_dither_strength, error_diffusion=error_diffusion, normalize_midpeaks=normalize_midpeaks, peak_width=peak_width, high_precision=precision, seed=seed) + poster_data = rasterix_data.get('posterize', {}) + if use_posterize and poster_data: + pil_img = img_posterize.img_posterize(image=pil_img, channels_data=poster_data) + if use_ai_detection_bypasser: pil_img = isgen_detect_ext_full.bypass_ai_detector(image=pil_img, freq_strength=adb_freq_strength, variance_strength=adb_variance_strength, unsharp_percent=adb_unsharp_percent, jpeg_cycles=adb_jpeg_cycles) @@ -633,6 +643,31 @@ class PrimereLevelEndpoints: return (utility.image_to_tensor(pil_img),) +class PrimerePosterize: + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("IMAGE",) + FUNCTION = "primere_posterize" + CATEGORY = TREE_RASTERIX + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE", {"forceInput": True}), + "use_posterize": ("BOOLEAN", {"default": False, "label_off": "Ignore posterize", "label_on": "Apply posterize"}), + "shades": ("INT", {"default": 255, "min": 1, "max": 255, "step": 1}), + "channels": (["Red", "Green", "Blue"], {"default": "Red"}), + } + } + + def primere_posterize(self, image, use_posterize, shades, channels): + pil_img = utility.tensor_to_image(image) + rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json') + rasterix_data = utility.json2tuple(rasterix_json_path) or {} + poster_data = rasterix_data.get('posterize', {}) + if use_posterize and poster_data: + pil_img = img_posterize.img_posterize(image=pil_img, channels_data=poster_data) + return (utility.image_to_tensor(pil_img),) class PrimereDithering: RETURN_TYPES = ("IMAGE",) diff --git a/__init__.py b/__init__.py index 11a9220..ce33d65 100644 --- a/__init__.py +++ b/__init__.py @@ -72,6 +72,7 @@ NODE_CLASS_MAPPINGS = { "PrimereHSL": Rasterix.PrimereHSL, "PrimereShadeDetailer": Rasterix.PrimereShadeDetailer, "PrimereLevelEndpoints": Rasterix.PrimereLevelEndpoints, + "PrimerePosterize": Rasterix.PrimerePosterize, "PrimereDithering": Rasterix.PrimereDithering, "PrimereAIDetectionBypasser": Rasterix.PrimereAIDetectionBypasser, "PrimereRasterixGrain": Rasterix.PrimereRasterixGrain, @@ -169,6 +170,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "PrimereHSL": "Primere Rasterix (HSL)", "PrimereShadeDetailer": "Primere Rasterix (Shade Detailer)", "PrimereLevelEndpoints": "Primere Rasterix (Level Endpoints)", + "PrimerePosterize": "Primere Rasterix (Posterize)", "PrimereDithering": "Primere Rasterix (Dithering)", "PrimereAIDetectionBypasser": "Primere Rasterix (AI Detection Bypasser)", "PrimereRasterixGrain": "Primere Rasterix (Grain)", diff --git a/components/images/histogram.py b/components/images/histogram.py index daf38fc..eac1ff5 100644 --- a/components/images/histogram.py +++ b/components/images/histogram.py @@ -6,10 +6,6 @@ import json from ..tree import PRIMERE_ROOT import os -# ───────────────────────────────────────────────────────────────────────────── -# Channel definitions -# ───────────────────────────────────────────────────────────────────────────── - _HIST_CH_DEFS = { "RGB": [(0, (1.0, 0.22, 0.22)), (1, (0.22, 1.0, 0.22)), (2, (0.22, 0.44, 1.0))], "RED": [(0, (1.0, 0.22, 0.22))], @@ -36,10 +32,6 @@ VALID_STYLES = { } -# ───────────────────────────────────────────────────────────────────────────── -# Internal helpers -# ───────────────────────────────────────────────────────────────────────────── - def _get_raw(arr: np.ndarray, ch_idx: int, precision: bool) -> np.ndarray: """Return 256-bin histogram for one channel.""" if precision: @@ -124,77 +116,34 @@ def _draw_lines(canvas, heights, color, hist_h, hist_w): for ci, cv in enumerate(color): canvas[rs[xs], xs, ci] = np.maximum(canvas[rs[xs], xs, ci], cv) - -# ───────────────────────────────────────────────────────────────────────────── -# Main function -# ───────────────────────────────────────────────────────────────────────────── - def rasterix_histogram_render( pil_img: Image.Image, channel: str = "RGB", style: str = "bars", precision: bool = False, ) -> Image.Image: - """ - Render a histogram visualisation of pil_img. - Args: - pil_img : PIL Image (RGB) - - channel : "RGB" | "RED" | "GREEN" | "BLUE" - Ignored by "parade" style (always shows all three). - Ignored by "heatmap" and "luma" (use fixed channel logic). - - style : One of: - # "gradient" — filled area with top fade (default) - "bars" — flat filled area - "lines" — thin outline only - # "glow" — bars + gaussian bloom - "waveform" — center-mirrored oscilloscope curve - "heatmap" — luminosity density with perceptual colour ramp - "stacked" — R/G/B stacked (non-overlapping areas) - # "dots" — vertical dot columns proportional to count - # "step" — raw unsmoothed step function (shows comb) - "luma" — gradient + white luminosity overlay curve - # "log" — log-scale Y axis gradient - "parade" — R | G | B side-by-side panels - # "percentile" — gradient + percentile marker lines - # "inverse" — light-background gradient - - precision : False = 8-bit raw histogram source - True = 16-bit raw histogram source (downsampled to 256 bins) - Note: rendering normalisation is intentionally identical for both - so equal input gives equal visual histogram height. - - Returns: - PIL Image (RGB) — 1024 × 256 px (parade: 1536 × 256 px) - """ if style not in VALID_STYLES: raise ValueError(f"style must be one of {sorted(VALID_STYLES)}, got '{style}'") arr = np.array(pil_img.convert("RGB"), dtype=np.float32) hist_h = 192 hist_w = 512 - # Keep visual scale identical between 8-bit and 16-bit rendering. - # Precision only changes raw-bin acquisition, not display normalisation. sqrt_norm = False sigma = 0.75 if style in ("bars", "step", "dots") else 1.0 smooth = _make_smooth(sigma) channels = _HIST_CH_DEFS.get(channel, _HIST_CH_DEFS["RGB"]) - # ── PARADE — special layout: three panels side by side ─────────────────── if style == "parade": panel_w = hist_w // 3 # 341 px each; total = 1023 px parade_w = panel_w * 3 canvas = np.full((hist_h, parade_w, 3), 18.0 / 255.0, dtype=np.float32) - # grid per panel for p in range(3): ox = p * panel_w for frac in (0.25, 0.5, 0.75): canvas[int((1.0-frac)*(hist_h-1)), ox:ox+panel_w] = 0.32 canvas[:, ox + int(frac*(panel_w-1)), :] = 0.32 - # separator if p > 0: canvas[:, ox, :] = 0.45 for p, (ch_idx, color) in enumerate(_HIST_CH_DEFS["RGB"]): @@ -209,19 +158,15 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ── All other styles use hist_w × hist_h canvas ─────────────────────────── if style == "inverse": canvas = _light_canvas(hist_h, hist_w) - # Darker curve colors for light background inv_colors = {0: (0.75, 0.10, 0.10), 1: (0.10, 0.65, 0.10), 2: (0.10, 0.25, 0.85)} draw_channels = [(idx, inv_colors.get(idx, col)) for idx, col in channels] else: canvas = _dark_canvas(hist_h, hist_w) draw_channels = channels - # ── Compute raw histograms ──────────────────────────────────────────────── raws = {ch_idx: _get_raw(arr, ch_idx, precision) for ch_idx, _ in channels} - # Also compute luma for luma/heatmap styles luma_raw = None if style in ("luma", "heatmap"): luma_arr = (0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2]) @@ -233,14 +178,10 @@ def rasterix_histogram_render( luma_raw, _ = np.histogram(luma_arr, bins=256, range=(0,256)) luma_raw = luma_raw.astype(np.float32) - # ───────────────────────────────────────────────────────────────────────── - # HEATMAP — luminosity density with black→blue→cyan→white ramp - # ───────────────────────────────────────────────────────────────────────── if style == "heatmap": norm = _normalise(luma_raw, smooth, sqrt_norm) x_idx = np.linspace(0, 255, hist_w) cols = np.interp(x_idx, np.arange(256), norm) - # Ramp: 0→black, 0.33→deep blue, 0.66→cyan, 1.0→white ramp_t = np.array([0.0, 0.33, 0.66, 1.0]) ramp_r = np.array([0.0, 0.05, 0.0, 1.0]) ramp_g = np.array([0.0, 0.05, 0.85, 1.0]) @@ -261,22 +202,16 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # STACKED — R bottom, G middle, B top (non-overlapping) - # ───────────────────────────────────────────────────────────────────────── if style == "stacked": x_idx = np.linspace(0, 255, hist_w) row_idx = np.arange(hist_h).reshape(-1, 1) - # Stack: at each x, allocate vertical space proportionally norms = [] for ch_idx, _ in _HIST_CH_DEFS["RGB"]: raw = _get_raw(arr, ch_idx, precision) norms.append(np.interp(x_idx, np.arange(256), _normalise(raw, smooth, sqrt_norm))) norms = np.array(norms) # (3, hist_w) total = norms.sum(axis=0) + 1e-6 - # Fractional heights per channel fracs = norms / total # (3, hist_w), each col sums to 1 - # Bottom channel (R), then G on top, then B on top cum_h = np.zeros(hist_w, dtype=np.float32) for layer, (ch_idx, color) in enumerate(_HIST_CH_DEFS["RGB"]): layer_h = (fracs[layer] * (hist_h - 1) * norms.max(axis=0) / norms.max()).astype(int) @@ -297,15 +232,11 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # STEP — raw unsmoothed bins, shows true quantization comb pattern - # ───────────────────────────────────────────────────────────────────────── if style == "step": row_idx = np.arange(hist_h).reshape(-1, 1) column_bin = np.minimum((np.arange(hist_w) * 256) // hist_w, 255).astype(np.int32) for ch_idx, color in draw_channels: raw = _get_raw(arr, ch_idx, precision) - # No smoothing — raw bin values normalised only if sqrt_norm: norm256 = np.sqrt(np.maximum(raw, 0)); norm256 /= (norm256.max() or 1.0) else: @@ -322,9 +253,6 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # WAVEFORM — center-line oscilloscope, mirrored above/below midpoint - # ───────────────────────────────────────────────────────────────────────── if style == "waveform": x_idx = np.linspace(0, 255, hist_w) mid = hist_h // 2 @@ -337,21 +265,16 @@ def rasterix_histogram_render( if amp[x] == 0: continue y_lo = np.clip(mid - amp[x], 0, hist_h-1) y_hi = np.clip(mid + amp[x], 0, hist_h-1) - # Gradient: bright at midline, fading to edges for y in range(y_lo, y_hi+1): dist = abs(y - mid) / max(amp[x], 1) brightness = max(0.25, 1.0 - dist * 0.7) for ci, cv in enumerate(color): canvas[y, x, ci] = max(canvas[y, x, ci], cv * brightness) - # Draw center marker line canvas[mid, :, :] = np.maximum(canvas[mid, :, :], 0.28) result = Image.fromarray( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # DOTS — vertical dot columns, spacing proportional to count - # ───────────────────────────────────────────────────────────────────────── if style == "dots": x_idx = np.linspace(0, 255, hist_w) n_dots = 32 # max dots per column @@ -361,7 +284,6 @@ def rasterix_histogram_render( cols = np.interp(x_idx, np.arange(256), norm) for x in range(hist_w): n = max(1, int(cols[x] * n_dots)) - # Distribute n dots evenly across the column height positions = np.linspace(hist_h - 2, int((1.0 - cols[x]) * (hist_h - 1)), n) for pos in positions: y = int(np.clip(pos, 0, hist_h - 1)) @@ -372,9 +294,6 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # LOG — log-scale Y axis - # ───────────────────────────────────────────────────────────────────────── if style == "log": x_idx = np.linspace(0, 255, hist_w) for ch_idx, color in draw_channels: @@ -386,20 +305,14 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # LUMA — gradient base + white luminosity curve on top - # ───────────────────────────────────────────────────────────────────────── if style == "luma": x_idx = np.linspace(0, 255, hist_w) - # Draw RGB gradient base first (semi-transparent feel via lower alpha) for ch_idx, color in draw_channels: raw = _get_raw(arr, ch_idx, precision) norm = _normalise(raw, smooth, sqrt_norm) _, heights = _cols_heights(norm, hist_w, hist_h) - # Draw at 55% brightness so luma curve stands out dimmed = tuple(v * 0.55 for v in color) _draw_gradient(canvas, heights, dimmed, hist_h, hist_w) - # Draw luminosity curve in white norm_luma = _normalise(luma_raw, smooth, sqrt_norm) cols_luma = np.interp(x_idx, np.arange(256), norm_luma) heights_luma = (cols_luma * (hist_h - 1)).astype(int) @@ -409,9 +322,6 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # PERCENTILE — gradient + vertical marker lines - # ───────────────────────────────────────────────────────────────────────── if style == "percentile": x_idx = np.linspace(0, 255, hist_w) for ch_idx, color in draw_channels: @@ -419,7 +329,6 @@ def rasterix_histogram_render( norm = _normalise(raw, smooth, sqrt_norm) _, heights = _cols_heights(norm, hist_w, hist_h) _draw_gradient(canvas, heights, color, hist_h, hist_w) - # Compute percentiles from first channel (or luma if RGB) if len(draw_channels) == 3: lum = 0.299*arr[:,:,0] + 0.587*arr[:,:,1] + 0.114*arr[:,:,2] flat = lum.ravel() @@ -443,9 +352,6 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # INVERSE — light background gradient - # ───────────────────────────────────────────────────────────────────────── if style == "inverse": x_idx = np.linspace(0, 255, hist_w) for ch_idx, color in draw_channels: @@ -457,10 +363,8 @@ def rasterix_histogram_render( fill_mask = row_idx >= (hist_h - heights) safe_h = np.maximum(heights, 1).astype(np.float32) dist_b = (hist_h - 1 - row_idx).astype(np.float32) - # Inverse gradient: dark at bottom, lighter toward top of fill grad = np.clip(0.15 + 0.85 * (1.0 - dist_b / safe_h), 0.0, 1.0) for ci, cv in enumerate(color): - # Subtract from white background canvas[:, :, ci] = np.where( fill_mask, np.minimum(canvas[:, :, ci], 1.0 - grad * cv * 0.7), @@ -469,9 +373,6 @@ def rasterix_histogram_render( np.clip(canvas * 255, 0, 255).astype(np.uint8), mode="RGB") return result - # ───────────────────────────────────────────────────────────────────────── - # Original styles: gradient, bars, lines, glow - # ───────────────────────────────────────────────────────────────────────── for ch_idx, color in draw_channels: raw = _get_raw(arr, ch_idx, precision) norm = _normalise(raw, smooth, sqrt_norm) diff --git a/components/images/img_blur.py b/components/images/img_blur.py index dd4bfe6..91c8208 100644 --- a/components/images/img_blur.py +++ b/components/images/img_blur.py @@ -33,7 +33,6 @@ def img_blur( effective_radius = radius * intensity - # ── Blur types ──────────────────────────────────────────────────────────── if blur_type == "gaussian": blurred = np.stack([ gaussian_filter(arr[..., c], sigma=effective_radius) @@ -56,7 +55,6 @@ def img_blur( ], axis=-1) elif blur_type == "bilateral": - # color_sigma: maps sensitivity 0.0 → 0.02 (very tight), 1.0 → 0.3 (loose) color_sigma = 0.02 + bilateral_edge_sensitivity * 0.28 blurred = _bilateral_blur(arr, spatial_sigma=effective_radius, color_sigma=color_sigma) @@ -68,7 +66,6 @@ def img_blur( for c in range(3) ], axis=-1) - # ── Edge-only mask ──────────────────────────────────────────────────────── if edge_only: grey = 0.299 * arr[..., 0] + 0.587 * arr[..., 1] + 0.114 * arr[..., 2] sx = sobel(grey, axis=0) @@ -76,11 +73,8 @@ def img_blur( edge_mag = np.sqrt(sx**2 + sy**2) edge_mag = np.clip(edge_mag / (edge_mag.max() + 1e-6), 0, 1) - # Apply threshold: remap [threshold … 1] → [0 … 1] so pixels above - # threshold are fully sharp, pixels below are progressively blurred. if edge_threshold > 0: - edge_mag = np.clip((edge_mag - edge_threshold) / - (1.0 - edge_threshold + 1e-6), 0, 1) + edge_mag = np.clip((edge_mag - edge_threshold) / (1.0 - edge_threshold + 1e-6), 0, 1) edge_mag = edge_mag[..., np.newaxis] blurred = arr * edge_mag + blurred * (1.0 - edge_mag) @@ -89,8 +83,6 @@ def img_blur( return Image.fromarray((result * 255).astype(np.uint8), mode="RGB") -# ── Kernel helpers ──────────────────────────────────────────────────────────── - def _motion_kernel(length: int, angle_deg: float) -> np.ndarray: angle_rad = np.deg2rad(angle_deg) cx, cy = length // 2, length // 2 diff --git a/components/images/img_dithering.py b/components/images/img_dithering.py index 418de14..2b1965f 100644 --- a/components/images/img_dithering.py +++ b/components/images/img_dithering.py @@ -2,10 +2,6 @@ import numpy as np from PIL import Image from numpy.lib.stride_tricks import sliding_window_view -# ───────────────────────────────────────────────────────────────────────────── -# Optional GPU / acceleration imports (graceful fallback) -# ───────────────────────────────────────────────────────────────────────────── - try: from numba import njit NUMBA_AVAILABLE = True @@ -15,10 +11,6 @@ except ImportError: def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> float: - """ - Return dither amplitude in output-code units (LSB of 8-bit domain). - (Already strengthened in previous version — unchanged) - """ base_lsb = max_val / 255.0 if not adaptive: return 1.5 * base_lsb @@ -27,9 +19,6 @@ def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> def _estimate_global_scale(arr: np.ndarray, max_val: float) -> float: - """ - Estimate effective tonal span (0..1) from channel min/max. - """ mins = arr.reshape(-1, 3).min(axis=0) maxs = arr.reshape(-1, 3).max(axis=0) spans = np.clip((maxs - mins) / max_val, 0.0, 1.0) @@ -37,7 +26,6 @@ def _estimate_global_scale(arr: np.ndarray, max_val: float) -> float: def _tpdf_noise(shape: tuple[int, int, int], amplitude: float) -> np.ndarray: - """Triangular PDF noise in [-amplitude, +amplitude], float32.""" h, w, c = shape rng = np.random.default_rng() u1 = rng.random((h, w, c), dtype=np.float32) @@ -46,7 +34,6 @@ def _tpdf_noise(shape: tuple[int, int, int], amplitude: float) -> np.ndarray: def _floyd_steinberg_quantize_python(arr: np.ndarray, max_val: float) -> np.ndarray: - """Original pure-Python Floyd-Steinberg (kept for when Numba is not used).""" work = np.clip(arr, 0.0, max_val).astype(np.float32, copy=True) h, w, c = work.shape for ch in range(c): @@ -93,7 +80,6 @@ def _floyd_steinberg_quantize_numba(arr: np.ndarray, max_val: float) -> np.ndarr def _get_spikiness_factor(c_hist: np.ndarray, total: float) -> float: - """Return 0.0–2.0 boost factor when histogram has tall spikes.""" if total <= 0: return 0.0 peak_ratio = c_hist.max() / (c_hist.mean() + 1e-8) @@ -106,23 +92,6 @@ def _normalize_midpeaks_channel( max_val: float, rng: np.random.Generator, ) -> np.ndarray: - """ - Histogram-aware anti-spike smoothing near empty bins (gaps). - - PRECISION-AWARE FIX (March 2026): - • 16-bit (high_precision=True): amp = (peak_width * 1.0) * (max_val / 255.0) - → exactly as you requested and liked. At peak_width=1 it already - smooths "every peaks" strongly — this is intentional and unchanged. - • 8-bit (high_precision=False): amp = (peak_width * 12.0) * (max_val / 255.0) - → much stronger base multiplier so that peak_width=6 (or even 3–4) - now produces a clearly visible histogram smoothing effect that - matches the "logical" strength you see in 16-bit. - • Automatic spikiness boost (up to 3×) is still applied on top for both - bit depths. - • No other logic was changed — the qualify_mask, gap detection, and - clipping are identical. The only difference is the base amplitude - per bit depth so the visual/histogram result feels consistent. - """ n_bins = int(max_val) + 1 result = channel.copy() c_int = np.clip(np.round(result).astype(np.int64), 0, int(max_val)) @@ -132,7 +101,6 @@ def _normalize_midpeaks_channel( if not gap_arr.any(): return result - # ── BIT-DEPTH-SPECIFIC STRENGTH (16-bit untouched, 8-bit now strong) ───── if max_val >= 65535.0: # 16-bit — exactly as you wanted amp = (peak_width * 0.5) * (max_val / 255.0) else: # 8-bit — fixed to give visible effect @@ -165,14 +133,6 @@ def img_dithering( numba_accelerated: bool = True, seed: int | None = None, ) -> Image.Image: - """ - Standalone quantization dither stage for post-processing. - - PRECISION HANDLING IS NOW LOGICALLY CONSISTENT: - • 16-bit at peak_width=1 behaves exactly as before (strong smoothing). - • 8-bit now gives a clearly visible histogram-smoothing effect at - reasonable peak_width values (try 3–6). No more "do nothing". - """ if not (1 <= peak_width <= 10): raise ValueError(f"peak_width must be 1–10, got {peak_width}") @@ -186,14 +146,12 @@ def img_dithering( scale_factor = max_val / 255.0 arr = arr_8f * scale_factor if high_precision else arr_8f - # ── 1. Mid-peak spike removal (now consistent across bit depths) ───────── if normalize_midpeaks: for ch in range(3): channel_seed = int(base_rng.integers(0, 2**31 - 1)) rng = np.random.default_rng(channel_seed) arr[:, :, ch] = _normalize_midpeaks_channel(arr[:, :, ch], peak_width, max_val, rng) - # ── 2. Final quantization stage ────────────────────────────────────────── if error_diffusion: pre_amp = 0.5 * (max_val / 255.0) arr = arr + _tpdf_noise(arr.shape, pre_amp) diff --git a/components/images/img_film_grain.py b/components/images/img_film_grain.py index e7b8b1b..d94cd66 100644 --- a/components/images/img_film_grain.py +++ b/components/images/img_film_grain.py @@ -115,8 +115,6 @@ def img_film_grain( else: tint = TINTS[color_tint] - # ── Apply grain ─────────────────────────────────────────────────────────── - # Scale noise to sigma (pixel intensity units), apply lum_mask, apply tint grain_r = noise_r * sigma * lum_mask * tint[0] grain_g = noise_g * sigma * lum_mask * tint[1] grain_b = noise_b * sigma * lum_mask * tint[2] diff --git a/components/images/img_film_rendering.py b/components/images/img_film_rendering.py index 0c80174..57cacf3 100644 --- a/components/images/img_film_rendering.py +++ b/components/images/img_film_rendering.py @@ -2,7 +2,6 @@ import numpy as np from PIL import Image FILM_PRESETS = { - "fuji_astia_100_CF": { "desc": "Fuji Astia 100 — soft, low contrast, neutral skin tones, subtle colours", "iso": 100, "grain_type": "fine", "grain_color": "color", diff --git a/components/images/img_hue_saturation.py b/components/images/img_hue_saturation.py index e421738..4026417 100644 --- a/components/images/img_hue_saturation.py +++ b/components/images/img_hue_saturation.py @@ -14,7 +14,6 @@ def img_hue_saturation( if not (0 <= channel_width <= 100): raise ValueError(f"channel_width must be 0 … 100, got {channel_width}") - # ── Passthrough short-circuit ───────────────────────────────────────────── def _is_zero(v): return v == 0 or v is None @@ -32,7 +31,6 @@ def img_hue_saturation( img = image.convert("RGB") arr = np.array(img, dtype=np.float32) / 255.0 - # ── RGB → HSV ───────────────────────────────────────────────────────────── R, G, B = arr[:,:,0], arr[:,:,1], arr[:,:,2] Cmax = np.maximum(np.maximum(R, G), B) Cmin = np.minimum(np.minimum(R, G), B) @@ -54,7 +52,6 @@ def img_hue_saturation( feather_deg = 10 + t * 35 # 10° … 45° outer_deg = hard_deg + feather_deg - # ── Accumulate adjustments across all channels ──────────────────────────── total_hue = np.zeros_like(h) total_sat = np.zeros_like(s) total_lightness = np.zeros_like(h) @@ -84,29 +81,19 @@ def img_hue_saturation( total_lightness += mask * (params.get('lightness', 0) / 100.0) total_vibrance += mask * (params.get('vibrance', 0) / 100.0) - # ── Apply hue ───────────────────────────────────────────────────────────── h_new = (h + total_hue) % 360.0 - - # ── Apply saturation ────────────────────────────────────────────────────── - s_new = np.where(total_sat >= 0, - s + total_sat * (1.0 - s), - s + total_sat * s) + s_new = np.where(total_sat >= 0, s + total_sat * (1.0 - s), s + total_sat * s) s_new = np.clip(s_new, 0.0, 1.0) - # ── Apply vibrance (with optional skin protection) ──────────────────────── if np.any(total_vibrance != 0): if skin_protection: skin_diff = np.abs(((h_new - 25.0 + 180) % 360) - 180) - skin_mask = np.where(skin_diff <= 35.0, 1.0, - np.where(skin_diff <= 55.0, - 1.0 - (skin_diff - 35.0) / 20.0, 0.0)) + skin_mask = np.where(skin_diff <= 35.0, 1.0, np.where(skin_diff <= 55.0, 1.0 - (skin_diff - 35.0) / 20.0, 0.0)) vib_mask = 1.0 - skin_mask # 0 on skin, 1 elsewhere else: vib_mask = np.ones_like(h_new) - s_new += np.where(total_vibrance >= 0, - (1.0 - s_new) * vib_mask * total_vibrance, - s_new * vib_mask * total_vibrance) + s_new += np.where(total_vibrance >= 0, (1.0 - s_new) * vib_mask * total_vibrance, s_new * vib_mask * total_vibrance) s_new = np.clip(s_new, 0.0, 1.0) # ── HSV → RGB ───────────────────────────────────────────────────────────── @@ -129,9 +116,7 @@ def img_hue_saturation( # ── Apply lightness ─────────────────────────────────────────────────────── if np.any(total_lightness != 0): L3 = total_lightness[:, :, np.newaxis] - rgb_sectors = np.where(L3 > 0, - rgb_sectors + L3 * (1.0 - rgb_sectors), - rgb_sectors + L3 * rgb_sectors) + rgb_sectors = np.where(L3 > 0, rgb_sectors + L3 * (1.0 - rgb_sectors), rgb_sectors + L3 * rgb_sectors) result = np.clip(rgb_sectors, 0.0, 1.0) return Image.fromarray((result * 255).astype(np.uint8), mode="RGB") \ No newline at end of file diff --git a/components/images/img_levels_auto.py b/components/images/img_levels_auto.py index 71ca75f..eb78bd4 100644 --- a/components/images/img_levels_auto.py +++ b/components/images/img_levels_auto.py @@ -1,38 +1,16 @@ import numpy as np from PIL import Image - -# ───────────────────────────────────────────────────────────────────────────── -# Constants -# ───────────────────────────────────────────────────────────────────────────── - EDGE_SPREAD_RATIO = 8.0 / 255.0 # edge spread as fraction of max value # 8-bit: 8 bins, 16-bit: 2056 bins GAMMA_MIN = 0.25 GAMMA_MAX = 4.0 - -# ───────────────────────────────────────────────────────────────────────────── -# Step functions — callable independently -# ───────────────────────────────────────────────────────────────────────────── - def levels_detect_points( channel: np.ndarray, threshold: float, max_val: float = 255.0, ) -> tuple: - """ - Detect black and white points from a single channel histogram. - - Args: - channel : 2D float32 array, values 0–max_val - threshold : 0.0–100.0, percent of pixels to clip at each end - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - (black_point, white_point, scale) - scale = max_val / (white_point - black_point) - """ n_bins = int(max_val) + 1 hist, _ = np.histogram(channel, bins=n_bins, range=(0, max_val + 1)) cumulative = np.cumsum(hist) @@ -64,18 +42,6 @@ def levels_stretch( white_point: int, max_val: float = 255.0, ) -> np.ndarray: - """ - Linear stretch of channel values to [0 … max_val]. - - Args: - channel : 2D float32 array - black_point : input value that maps to 0 - white_point : input value that maps to max_val - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - Stretched float32 array clipped to [0, max_val] - """ scale = max_val / (white_point - black_point) stretched = (channel - black_point) * scale return np.clip(stretched, 0.0, max_val) @@ -88,23 +54,6 @@ def levels_edge_spread( white_point: int, max_val: float = 255.0, ) -> np.ndarray: - """ - Rank-based edge spread — always applied, not gated by any boolean. - - Spreads clipped pixels uniformly across [0 … edge_spread] and - [max_val-edge_spread … max_val]. Edge spread width scales proportionally - with max_val so the same fraction of the range is used at any bit depth. - - Args: - channel : original 2D float32 channel before stretch - stretched : 2D float32 array after stretch - black_point : black point used in stretch - white_point : white point used in stretch - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - Float32 array with edge pixels redistributed - """ edge_spread = EDGE_SPREAD_RATIO * max_val # ~8 at 8-bit, ~2056 at 16-bit result = stretched.copy() @@ -137,29 +86,11 @@ def levels_normalize_midpeaks( rng_spike: np.random.Generator, max_val: float = 255.0, ) -> np.ndarray: - """ - Anti-spike filter — smooths histogram bins near quantization gaps. - - A bin qualifies as a peak if it has at least one zero bin within - peak_width positions. Targeted TPDF dithering is applied to qualifying - pixels using a single pre-generated noise field (not per-bin), making - the operation O(1) in the number of qualifying bins. - - Args: - stretched : 2D float32 array after edge spread - peak_width : 1–10, distance from a gap that qualifies a bin - rng_spike : np.random.Generator (independent from gap dithering) - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - Float32 array with peak bins redistributed - """ n_bins = int(max_val) + 1 result = stretched.copy() s_int = np.clip(np.round(result).astype(np.int64), 0, int(max_val)) s_hist = np.bincount(s_int.ravel(), minlength=n_bins).astype(np.float64) - # Mid-range: exclude edge-spread zones (bins 0–edge and max-edge–max) edge_bins = int(EDGE_SPREAD_RATIO * max_val) + 1 lo = edge_bins hi = n_bins - edge_bins @@ -171,11 +102,9 @@ def levels_normalize_midpeaks( amp = (peak_width / 2.0) * (max_val / 255.0) # scale amplitude with bit depth half = amp / 2.0 - # Generate noise once for the full channel noise = (rng_spike.uniform(-half, half, result.shape).astype(np.float32) + rng_spike.uniform(-half, half, result.shape).astype(np.float32)) - # Vectorized near-gap detection via sliding window gap_arr = np.zeros(n_bins, dtype=bool) for g in gap_bins: gap_arr[g] = True @@ -186,7 +115,6 @@ def levels_normalize_midpeaks( windows = sliding_window_view(padded, 2 * pad + 1) near_gap = windows.any(axis=1) # shape (n_bins,) - # Build mask: all pixels in qualifying non-gap bins qualify_mask = np.zeros(result.shape, dtype=bool) for b in range(lo, hi): if s_hist[b] == 0 or not near_gap[b]: @@ -203,25 +131,6 @@ def levels_normalize_gaps( rng_gap: np.random.Generator, max_val: float = 255.0, ) -> np.ndarray: - """ - Anti-comb filter — TPDF gap dithering. - - Fills quantization gaps from non-integer stretch scale factors. - At 16-bit the gaps are far smaller (1/65535 vs 1/255) and largely - invisible, but dithering is still applied for completeness. - - Amplitude formula is ratio-based so it works at any bit depth: - amplitude = max(1.0, (scale / 1.275) ^ 2.2) × (max_val / 255) - - Args: - stretched : 2D float32 array - scale : stretch scale factor - rng_gap : np.random.Generator - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - Float32 array with gaps filled - """ amplitude = max(1.0, (scale / 1.275) ** 2.2) * (max_val / 255.0) half = amplitude / 2.0 noise = (rng_gap.uniform(-half, half, stretched.shape).astype(np.float32) + rng_gap.uniform(-half, half, stretched.shape).astype(np.float32)) @@ -233,22 +142,6 @@ def levels_auto_gamma( gamma_target: float, max_val: float = 255.0, ) -> np.ndarray: - """ - Auto gamma correction — pushes mean brightness toward gamma_target. - - Formula: gamma = log(current_mean_norm) / log(target_norm) - Applied: output = (input / max_val) ^ (1 / gamma) × max_val - Black and white stay anchored. gamma_target is always on 0–255 scale - regardless of bit depth — it is normalised internally. - - Args: - stretched : 2D float32 array, values 0–max_val - gamma_target : target mean brightness 0–255 (normalised internally) - max_val : 255.0 for 8-bit, 65535.0 for 16-bit - - Returns: - Float32 array with gamma correction applied - """ current_mean = float(stretched.mean()) low_guard = 0.5 * (max_val / 255.0) high_guard = max_val - low_guard @@ -268,10 +161,6 @@ def levels_auto_gamma( return np.clip(np.power(norm, 1.0 / gamma) * max_val, 0.0, max_val) -# ───────────────────────────────────────────────────────────────────────────── -# Main function -# ───────────────────────────────────────────────────────────────────────────── - def img_levels_auto( image: Image.Image, auto_normalize: bool = True, @@ -284,61 +173,6 @@ def img_levels_auto( precision: bool = False, seed: int | None = None, ) -> Image.Image: - """ - Photoshop-style per-channel auto levels normalization. - - Args: - image : PIL Image (RGB) - - auto_normalize : True = apply auto levels (default). - False = passthrough, return image unchanged. - - threshold : 0.0 … 100.0. Percent of total pixels per channel - used to determine black and white points. - ~1–2 = subtle, ~5 = moderate, ~10+ = aggressive. - - normalize_gaps : True = Anti-comb filter. TPDF dithering fills - quantization gaps. Independent of normalize_midpeaks. - Default: True. - - normalize_midpeaks : True = Anti-spike filter. Smooths bins near gaps. - False = completely skipped regardless of other flags. - Default: False. - - peak_width : 1 … 10. Distance from a gap that qualifies a bin - as a peak for smoothing. Only used when - normalize_midpeaks=True. - 1 = only directly adjacent bins (surgical) - 3 = within 3 bins of any gap (default) - 10 = wide smoothing - - auto_gamma : True = auto per-channel gamma after stretch. - Default: True. - - gamma_target : 0 … 255. Target mean brightness for auto gamma. - 128 = neutral (default), 110 = moody, 150 = airy. - Always specified on 0–255 scale regardless of - precision setting. - - precision : False = 8-bit pipeline, returns PIL Image RGB. - True = 16-bit pipeline (65536 histogram bins), - returns PIL Image RGB encoded at 16-bit precision - scaled back to 8-bit output. Use for AI-generated - tensors where higher internal precision reduces - quantization artefacts before final 8-bit output. - Default: False. - - Returns: - PIL Image (RGB) - - Pipeline per channel: - 1. levels_detect_points — black / white point via threshold - 2. levels_stretch — linear stretch to [0 … max_val] - 3. levels_edge_spread — rank-based edge spread (always on) - 4. levels_normalize_midpeaks — peak smoothing (if normalize_midpeaks) - 5. levels_normalize_gaps — TPDF gap dithering (if normalize_gaps) - 6. levels_auto_gamma — gamma correction (if auto_gamma) - """ img = image.convert("RGB") if not auto_normalize: @@ -351,11 +185,7 @@ def img_levels_auto( if not (1 <= peak_width <= 10): raise ValueError(f"peak_width must be 1–10, got {peak_width}") - # ── Bit depth configuration ─────────────────────────────────────────────── max_val = 65535.0 if precision else 255.0 - - # ── Load image into float array ─────────────────────────────────────────── - # Always read as 8-bit uint8 from PIL, then scale up to max_val if needed arr_8 = np.array(img, dtype=np.float32) # 0–255 always if precision: arr = arr_8 * (65535.0 / 255.0) # scale to 0–65535 @@ -368,35 +198,19 @@ def img_levels_auto( channel_seed_spike = int(base_rng.integers(0, 2**31 - 1)) rng_gap = np.random.default_rng(channel_seed_gap) rng_spike = np.random.default_rng(channel_seed_spike) - channel = arr[:, :, ch] - - # 1. Detect black / white points black_point, white_point, scale = levels_detect_points(channel, threshold, max_val) - - # 2. Stretch stretched = levels_stretch(channel, black_point, white_point, max_val) - - # 3. Edge spread (always on) stretched = levels_edge_spread(channel, stretched, black_point, white_point, max_val) - - # 4. Peak smoothing (before gap dithering) if normalize_midpeaks: stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike, max_val) - - # 5. Gap dithering if normalize_gaps: stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val) - - # 6. Auto gamma if auto_gamma: stretched = levels_auto_gamma(stretched, gamma_target, max_val) - out[:, :, ch] = stretched - # ── Convert back to uint8 for PIL output ────────────────────────────────── if precision: - # Scale 16-bit result back to 8-bit for PIL output out_8 = np.clip(out * (255.0 / 65535.0), 0, 255).astype(np.uint8) else: out_8 = np.clip(out, 0, 255).astype(np.uint8) diff --git a/components/images/img_levels_compress.py b/components/images/img_levels_compress.py index 3c61034..b7cbaaa 100644 --- a/components/images/img_levels_compress.py +++ b/components/images/img_levels_compress.py @@ -9,80 +9,6 @@ def img_levels_compress( skip_if_no_clip: bool = False, high_precision: bool = False, ) -> Image.Image: - """ - Proportional histogram compression — the inverse of auto-levels stretch. - - Shifts the black point upward and the white point downward by the specified - offsets, compressing all pixel values proportionally into the narrower range - without clipping any pixels. The original tonal relationships are preserved. - - This is the manual counterpart to img_levels_auto: while auto-levels - automatically STRETCHES the histogram to fill [0, 255], this function - COMPRESSES the histogram to avoid pure black (0) and pure white (255), - which is useful for preventing full-black / full-white areas in compositing - or for adding a subtle lift before further processing. - - Args: - image : PIL Image (RGB) - - black_offset : 0.0 … 25.0 (0–10% of 255). - Lifts the output black point. - 0 = no change (pixels can still reach 0). - 10 = darkest pixel maps to 10 instead of 0. - 25 = darkest pixel maps to 25 (maximum lift). - - white_offset : 0.0 … 25.0 (0–10% of 255). - Lowers the output white point. - 0 = no change (pixels can still reach 255). - 5 = brightest pixel maps to 250 instead of 255. - 25 = brightest pixel maps to 230 (maximum pull-down). - - skip_if_no_clip : Controls per-side, per-channel behaviour when the - channel's data does not actually reach the extreme. - - False (default) — Always apply: - Both offsets are applied to every channel regardless - of whether the channel data reaches 0 or 255. - Use this for a uniform look across all channels. - - True — Skip if no data reaches the extreme: - Each offset is applied to a channel side only if - the channel's data reaches that side threshold: - Black side: apply only if channel_min <= black_offset - White side: apply only if channel_max >= (255 - white_offset) - If a channel's brightest pixel is 245 and white_offset=5 - (threshold = 250), the white compression is skipped for - that channel — it has no pixels to protect. - Each side is evaluated independently. - Use this when channels have unequal tonal ranges and - you only want to protect sides that actually clip. - - high_precision : False = 8-bit pipeline (default, 0–255 range). - True = 16-bit pipeline (0–65535 range, offsets - scale proportionally with max_val). - Both return PIL Image RGB (uint8). 16-bit precision - is internal — more accurate intermediate computation. - - Returns: - PIL Image (RGB) - - Formula (per active side): - Both sides active: - output = black_offset + input × (max_val − white_offset − black_offset) / max_val - - Black side only: - output = black_offset + input × (max_val − black_offset) / max_val - - White side only: - output = input × (max_val − white_offset) / max_val - - Neither side active (skip_if_no_clip=True, no data at extremes): - output = input (passthrough) - - Passthrough conditions: - - black_offset == 0 AND white_offset == 0 - - OR skip_if_no_clip=True and no channel side reaches its threshold - """ if not (0.0 <= black_offset <= 25.0): raise ValueError(f"black_offset must be 0.0–25.0, got {black_offset}") if not (0.0 <= white_offset <= 25.0): @@ -94,12 +20,10 @@ def img_levels_compress( img = image.convert("RGB") max_val = 65535.0 if high_precision else 255.0 - # Scale offsets from 8-bit units to internal precision scale_factor = max_val / 255.0 bo = black_offset * scale_factor # e.g. 10 → 10 (8-bit) or 2570 (16-bit) wo = white_offset * scale_factor - # Load as float32, scale to internal range arr_8 = np.array(img, dtype=np.float32) # 0–255 always arr = arr_8 * scale_factor if high_precision else arr_8.copy() @@ -107,13 +31,7 @@ def img_levels_compress( for ch in range(3): channel = arr[:, :, ch] - if skip_if_no_clip: - # Per-side threshold check: - # Black side fires only if the channel has pixels at or below the - # new black point threshold. - # White side fires only if the channel has pixels at or above the - # new white point threshold. ch_min = float(channel.min()) ch_max = float(channel.max()) black_active = ch_min <= bo @@ -122,22 +40,17 @@ def img_levels_compress( black_active = True white_active = True - # Determine effective offsets for this channel bo_eff = bo if black_active else 0.0 wo_eff = wo if white_active else 0.0 if bo_eff == 0.0 and wo_eff == 0.0: - # Neither side active — passthrough this channel out[:, :, ch] = channel continue - # Proportional compression formula - # output = bo_eff + input × (max_val − wo_eff − bo_eff) / max_val compress_range = max_val - wo_eff - bo_eff scale = compress_range / max_val out[:, :, ch] = bo_eff + channel * scale - # Convert back to uint8 if high_precision: out_8f = np.clip(out * (255.0 / max_val), 0, 255) else: diff --git a/components/images/img_posterize.py b/components/images/img_posterize.py new file mode 100644 index 0000000..9ca7df4 --- /dev/null +++ b/components/images/img_posterize.py @@ -0,0 +1,35 @@ +import numpy as np +from PIL import Image + + +def img_posterize( + image: Image.Image, + channels_data: dict, +) -> Image.Image: + levels_r = int(channels_data.get("Red", 255)) + levels_g = int(channels_data.get("Green", 255)) + levels_b = int(channels_data.get("Blue", 255)) + + levels_r = max(1, min(255, levels_r)) + levels_g = max(1, min(255, levels_g)) + levels_b = max(1, min(255, levels_b)) + + if levels_r == 255 and levels_g == 255 and levels_b == 255: + return image.convert("RGB") + + img = image.convert("RGB") + arr = np.array(img, dtype=np.float32) / 255.0 + + def _posterize(ch_arr, levels): + if levels >= 255: + return ch_arr + step = 1.0 / levels + return np.floor(ch_arr / step) * step + + out = np.stack([ + _posterize(arr[..., 0], levels_r), + _posterize(arr[..., 1], levels_g), + _posterize(arr[..., 2], levels_b), + ], axis=-1) + + return Image.fromarray(np.clip(out * 255, 0, 255).astype(np.uint8), mode="RGB") diff --git a/components/images/img_shade_level.py b/components/images/img_shade_level.py index 1982079..5759a7c 100644 --- a/components/images/img_shade_level.py +++ b/components/images/img_shade_level.py @@ -9,38 +9,6 @@ def img_shade_level( radius: float = 0, strength: float = 0.5, ) -> Image.Image: - """ - Micro-contrast / shade density adjustment on the L channel only. - Hue and saturation are never modified. - - Args: - image : PIL Image (RGB) - shade_level : -100 … +100. 0 = no change (passthrough). - Positive = amplify local shade differences (more texture, - less plastic/flat look). - Negative = suppress local shade differences (smoother, - more painterly). - radius : 0.0 … 50.0. Gaussian blur radius in pixels. - Defines the spatial scale of the effect: - 0 = auto: ~1% of the shorter image dimension. - 1–3 = pixel-level texture and fine grain only. - 4–10 = mid-level detail, good for portraits. - 15–50 = broad tonal area transitions. - Tip: smaller radius = sharper/grainier effect. - larger radius = smoother/broader contrast lift. - strength : 0.0 … 1.0. Controls the ceiling of what shade_level - ±100 can do, and balances the positive/negative asymmetry. - 0.0 = very subtle — positive and negative equally gentle. - 0.5 = default — positive ~3× stronger than negative - (amplifying detail is more dramatic - than suppressing it). - 1.0 = maximum — positive very aggressive, - negative also stronger than default. - Positive multiplier: 1.0 + strength * 4.0 (1.0 … 5.0) - Negative multiplier: 0.5 + strength * 1.0 (0.5 … 1.5) - Returns: - PIL Image (RGB) - """ if shade_level == 0: return image.convert("RGB") @@ -56,13 +24,8 @@ def img_shade_level( img = image.convert("RGB") arr = np.array(img, dtype=np.float32) / 255.0 - # ── RGB → Lab ───────────────────────────────────────────────────────────── def rgb_to_lab(rgb): - linear = np.where( - rgb <= 0.04045, - rgb / 12.92, - ((rgb + 0.055) / 1.055) ** 2.4 - ) + linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4) M = np.array([ [0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], @@ -85,8 +48,7 @@ def img_shade_level( fz = fy - b / 200.0 def f_inv(t): delta = 6.0 / 29.0 - return np.where(t > delta, t ** 3, - 3 * delta**2 * (t - 4.0/29.0)) + return np.where(t > delta, t ** 3, 3 * delta**2 * (t - 4.0/29.0)) xyz_n = np.array([0.95047, 1.00000, 1.08883], dtype=np.float32) xyz = np.stack([f_inv(fx), f_inv(fy), f_inv(fz)], axis=-1) * xyz_n M_inv = np.array([ @@ -99,20 +61,15 @@ def img_shade_level( 1.055 * np.power(np.clip(linear, 0, None), 1.0/2.4) - 0.055) return np.clip(srgb, 0.0, 1.0) - # ── Resolve radius ───────────────────────────────────────────────────────── lab = rgb_to_lab(arr) L = lab[..., 0] H, W = L.shape r = radius if radius > 0.0 else max(1.0, min(H, W) * 0.01) - # ── Detail layer ────────────────────────────────────────────────────────── L_blurred = gaussian_filter(L, sigma=r) detail = L - L_blurred - # ── Strength-controlled asymmetric multiplier ───────────────────────────── - # Positive ceiling: 1.0 … 5.0 (amplifying detail is inherently stronger) - # Negative ceiling: 0.5 … 1.5 (suppression is gentler by nature) if shade_level > 0: multiplier = 1.0 + strength * 4.0 else: diff --git a/components/images/img_white_balance.py b/components/images/img_white_balance.py index 6cf1284..5588f87 100644 --- a/components/images/img_white_balance.py +++ b/components/images/img_white_balance.py @@ -20,21 +20,18 @@ def img_white_balance( def kelvin_to_rgb(K): K = K / 100.0 - # Red if K <= 66: R = 255.0 else: R = 329.698727446 * ((K - 60) ** -0.1332047592) R = np.clip(R, 0, 255) - # Green if K <= 66: G = 99.4708025861 * np.log(K) - 161.1195681661 else: G = 288.1221695283 * ((K - 60) ** -0.0755148492) G = np.clip(G, 0, 255) - # Blue if K >= 66: B = 255.0 elif K <= 19: @@ -45,18 +42,14 @@ def img_white_balance( return np.array([R, G, B]) / 255.0 - # RGB at target temperature and at neutral 6500K rgb_target = kelvin_to_rgb(temperature) rgb_neutral = kelvin_to_rgb(6500) - # Per-channel gain relative to neutral with np.errstate(divide='ignore', invalid='ignore'): gain = np.where(rgb_neutral > 0, rgb_target / rgb_neutral, 1.0) - # ── Apply temperature gain ───────────────────────────────────────────────── arr = arr * gain - # ── Apply tint (green ↔ magenta on green channel) ───────────────────────── if tint != 0: tint_gain = 1.0 + (tint / 100.0) * 0.3 arr[..., 1] = arr[..., 1] * tint_gain diff --git a/front_end/primere_rasterix.js b/front_end/primere_rasterix.js index 113e3e3..b058900 100644 --- a/front_end/primere_rasterix.js +++ b/front_end/primere_rasterix.js @@ -1,9 +1,10 @@ import { app } from "/scripts/app.js"; -const CB_TONES = ["highlights", "midtones", "shadows"]; -const HS_CHANNELS = ["master", "red", "green", "blue"]; -const ST_ZONES = ["highlights", "midtones", "shadows", "blacks"]; -const SH_MODES = ["fine", "medium", "broad"]; +const CB_TONES = ["highlights", "midtones", "shadows"]; +const HS_CHANNELS = ["master", "red", "green", "blue"]; +const ST_ZONES = ["highlights", "midtones", "shadows", "blacks"]; +const SH_MODES = ["fine", "medium", "broad"]; +const POST_CHANNELS = ["Red", "Green", "Blue"]; function buildFilmPresetMap(allPresets) { const byType = {}; @@ -17,36 +18,32 @@ function buildFilmPresetMap(allPresets) { return byType; } -const cbDefault = () => ({ +const cbDefault = () => ({ highlights: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 }, midtones: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 }, shadows: { cyan_red: 0, magenta_green: 0, yellow_blue: 0 }, }); - -const hsDefault = () => ({ +const hsDefault = () => ({ master: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 }, red: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 }, green: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 }, blue: { hue: 0, saturation: 0, lightness: 0, vibrance: 0 }, }); - -const stDefault = () => ({ - highlights: 0, - midtones: 0, - shadows: 0, - blacks: 0, -}); - -const shDefault = () => ({ +const stDefault = () => ({ highlights: 0, midtones: 0, shadows: 0, blacks: 0 }); +const shDefault = () => ({ fine: { shade_level: 0, shade_radius: 0 }, medium: { shade_level: 0, shade_radius: 0 }, broad: { shade_level: 0, shade_radius: 0 }, }); +const postDefault = () => ({ Red: 255, Green: 255, Blue: 255 }); async function rasterixLoad() { try { const resp = await fetch('/primere_rasterix_read'); - if (!resp.ok) return { color_balance: cbDefault(), hue_saturation: hsDefault(), selective_tone: stDefault(), shade: shDefault() }; + if (!resp.ok) return { + color_balance: cbDefault(), hue_saturation: hsDefault(), + selective_tone: stDefault(), shade: shDefault(), posterize: postDefault(), + }; const data = await resp.json(); const cb = data.color_balance || {}; @@ -65,9 +62,16 @@ async function rasterixLoad() { for (const m of SH_MODES) if (!sh[m]) sh[m] = { shade_level: 0, shade_radius: 0 }; - return { color_balance: cb, hue_saturation: hs, selective_tone: st, shade: sh }; + const post = data.posterize || {}; + for (const ch of POST_CHANNELS) + if (post[ch] === undefined) post[ch] = 255; + + return { color_balance: cb, hue_saturation: hs, selective_tone: st, shade: sh, posterize: post }; } catch { - return { color_balance: cbDefault(), hue_saturation: hsDefault(), selective_tone: stDefault(), shade: shDefault() }; + return { + color_balance: cbDefault(), hue_saturation: hsDefault(), + selective_tone: stDefault(), shade: shDefault(), posterize: postDefault(), + }; } } @@ -87,7 +91,11 @@ app.registerExtension({ name: "Primere.Rasterix", async beforeRegisterNodeDef(nodeType, nodeData, app) { - const rasterixNodes = ["PrimereRasterix", "PrimereSelectiveTone", "PrimereColorBalance", "PrimereHSL", "PrimereShadeDetailer", "PrimereHistogram", "PrimereFilmRendering"]; + const rasterixNodes = [ + "PrimereRasterix", "PrimereSelectiveTone", "PrimereColorBalance", + "PrimereHSL", "PrimereShadeDetailer", "PrimereHistogram", + "PrimereFilmRendering", "PrimerePosterize", + ]; if (!rasterixNodes.includes(nodeData.name)) return; const onNodeCreated = nodeType.prototype.onNodeCreated; @@ -97,42 +105,43 @@ app.registerExtension({ const node = this; const fw = (name) => node.widgets?.find(w => w.name === name); - // Color Balance const wTone = fw("color_balance_tone"); const wCR = fw("color_balance_cyan_red"); const wMG = fw("color_balance_magenta_green"); const wYB = fw("color_balance_yellow_blue"); - // Hue / Saturation const wHsCh = fw("hsl_channel"); const wHsHue = fw("hsl_hue"); const wHsSat = fw("hsl_saturation"); const wHsLit = fw("hsl_lightness"); const wHsVib = fw("hsl_vibrance"); - // Selective Tone const wStZone = fw("selective_tone_zone"); const wStVal = fw("selective_tone_value"); - // Shade const wShMode = fw("detail_mode"); const wShLvl = fw("shade_level"); const wShRad = fw("shade_radius"); - let cbStore = cbDefault(); - let hsStore = hsDefault(); - let stStore = stDefault(); - let shStore = shDefault(); + const wPostCh = fw("channels"); + const wPostSh = fw("shades"); - let prevTone = wTone?.value ?? "midtones"; - let prevHsCh = wHsCh?.value ?? "master"; - let prevStZn = wStZone?.value ?? "midtones"; - let prevShMd = wShMode?.value ?? "medium"; - let updating = false; + let cbStore = cbDefault(); + let hsStore = hsDefault(); + let stStore = stDefault(); + let shStore = shDefault(); + let postStore = postDefault(); + + let prevTone = wTone?.value ?? "midtones"; + let prevHsCh = wHsCh?.value ?? "master"; + let prevStZn = wStZone?.value ?? "midtones"; + let prevShMd = wShMode?.value ?? "medium"; + let prevPostCh = wPostCh?.value ?? "Red"; + let updating = false; let histogramDebounceTimer = null; function applyFilmTypeFilter() { - const filmTypeWidget = fw("film_type"); + const filmTypeWidget = fw("film_type"); const filmRenderingWidget = fw("film_rendering"); if (!filmTypeWidget || !filmRenderingWidget) return; @@ -143,14 +152,14 @@ app.registerExtension({ } const selectedType = String(filmTypeWidget.value || "All"); - const allValues = node.__primereFilmAllPresets; - const byType = node.__primereFilmByType || {}; - const nextValues = selectedType === "All" + const allValues = node.__primereFilmAllPresets; + const byType = node.__primereFilmByType || {}; + const nextValues = selectedType === "All" ? allValues - : (byType[selectedType] && byType[selectedType].length > 0 ? byType[selectedType] : allValues); + : (byType[selectedType]?.length > 0 ? byType[selectedType] : allValues); - filmRenderingWidget.options = filmRenderingWidget.options || {}; - filmRenderingWidget.options.values = [...nextValues]; + filmRenderingWidget.options = filmRenderingWidget.options || {}; + filmRenderingWidget.options.values = [...nextValues]; if (!nextValues.includes(filmRenderingWidget.value)) { filmRenderingWidget.value = nextValues[0] || filmRenderingWidget.value; @@ -192,8 +201,8 @@ app.registerExtension({ function histogramFileUrl(showInput, channel, style) { const prefix = showInput ? "input" : "output"; - const ch = (channel || "RGB").toLowerCase(); - const st = style || "bars"; + const ch = (channel || "RGB").toLowerCase(); + const st = style || "bars"; return `/extensions/ComfyUI_Primere_Nodes/images/${prefix}_histogram_${ch}_${st}.jpg`; } @@ -214,13 +223,13 @@ app.registerExtension({ async function generateHistogram(showInput, channel, style) { try { await fetch('/primere_rasterix_histogram_generate', { - method: 'POST', + method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - histogram_source: showInput, + body: JSON.stringify({ + histogram_source: showInput, histogram_channel: channel || "RGB", - histogram_style: style || "bars", - precision: fw("precision")?.value ?? false, + histogram_style: style || "bars", + precision: fw("precision")?.value ?? false, }), }); } catch (e) { @@ -247,19 +256,16 @@ app.registerExtension({ function currentHistState() { return { - enabled: fw("show_histogram")?.value ?? false, - showInput: fw("histogram_source")?.value ?? false, - channel: fw("histogram_channel")?.value ?? "RGB", - style: fw("histogram_style")?.value ?? "bars", + enabled: fw("show_histogram")?.value ?? false, + showInput: fw("histogram_source")?.value ?? false, + channel: fw("histogram_channel")?.value ?? "RGB", + style: fw("histogram_style")?.value ?? "bars", }; } - node.onExecuted = async function() { + node.onExecuted = async function () { const { enabled, showInput, channel, style } = currentHistState(); - if (!enabled) { - showHistogramOffImage(); - return; - } + if (!enabled) { showHistogramOffImage(); return; } if (await histogramFileExists(showInput, channel, style)) { updateHistogramDisplay(showInput, channel, style); return; @@ -268,7 +274,7 @@ app.registerExtension({ updateHistogramDisplay(showInput, channel, style); }; - // ── Color Balance ───────────────────────────────────────────────── + // Color Balance function applyCbSliders(tone) { if (!wCR) return; updating = true; @@ -285,7 +291,7 @@ app.registerExtension({ rasterixSave('color_balance', cbStore); } - // ── Hue / Saturation ────────────────────────────────────────────── + // Hue / Saturation function applyHsSliders(ch) { if (!wHsHue) return; updating = true; @@ -303,7 +309,7 @@ app.registerExtension({ rasterixSave('hue_saturation', hsStore); } - // ── Selective Tone ──────────────────────────────────────────────── + // Selective Tone function applyStSlider(zone) { if (!wStVal) return; updating = true; @@ -317,7 +323,7 @@ app.registerExtension({ rasterixSave('selective_tone', stStore); } - // ── Shade ───────────────────────────────────────────────────────── + // Shade function applyShSliders(mode) { if (!wShLvl) return; updating = true; @@ -333,24 +339,41 @@ app.registerExtension({ rasterixSave('shade', shStore); } - // ── Initial load ────────────────────────────────────────────────── + // Posterize + function applyPostSlider(ch) { + if (!wPostSh) return; + updating = true; + wPostSh.value = postStore[ch] ?? 255; + updating = false; + app.canvas?.setDirty(true); + } + function capturePostSlider(ch) { + if (!wPostSh) return; + postStore[ch] = wPostSh.value; + rasterixSave('posterize', postStore); + } + + // Initial load rasterixLoad().then(loaded => { - cbStore = loaded.color_balance; - hsStore = loaded.hue_saturation; - stStore = loaded.selective_tone; - shStore = loaded.shade; + cbStore = loaded.color_balance; + hsStore = loaded.hue_saturation; + stStore = loaded.selective_tone; + shStore = loaded.shade; + postStore = loaded.posterize; applyCbSliders(prevTone); applyHsSliders(prevHsCh); applyStSlider(prevStZn); applyShSliders(prevShMd); + applyPostSlider(prevPostCh); }); - // ── Widget change handler ───────────────────────────────────────── + // Widget change handler node.onWidgetChanged = function (name, value) { if (updating) return; if (name === "film_type") { applyFilmTypeFilter(); + } else if (name === "color_balance_tone") { captureCbSliders(prevTone); applyCbSliders(value); @@ -391,6 +414,13 @@ app.registerExtension({ ) { captureShSliders(wShMode?.value ?? prevShMd); + } else if (name === "channels") { + capturePostSlider(prevPostCh); + applyPostSlider(value); + prevPostCh = value; + } else if (name === "shades") { + capturePostSlider(wPostCh?.value ?? prevPostCh); + } else if (name === "histogram_source") { const { enabled, channel, style } = currentHistState(); if (enabled) requestHistogramSwitch(value, channel, style); diff --git a/requirements.txt b/requirements.txt index b642812..e87529d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -deepface Pillow PyYAML Requests @@ -52,4 +51,5 @@ openai google-genai>=1.65.0 dotenv elevenlabs -python-magic-bin \ No newline at end of file +python-magic-bin +deepface \ No newline at end of file