diff --git a/Nodes/Dashboard.py b/Nodes/Dashboard.py index e9e2453..fdca74c 100644 --- a/Nodes/Dashboard.py +++ b/Nodes/Dashboard.py @@ -2250,11 +2250,11 @@ class PrimereRasterix: "skip_if_no_clip": ("BOOLEAN", {"default": False, "label_off": "Offset all values", "label_on": "Skip if no clips"}), "normalize_gaps": ("BOOLEAN", {"default": False, "label_on": "Anti-comb filter: ON", "label_off": "Anti-comb filter: OFF"}), - "dither_quantization": ("BOOLEAN", {"default": False, "label_off": "Dither quantization OFF", "label_on": "Dither quantization ON"}), - "adaptive_dither_strength": ("BOOLEAN", {"default": False, "label_off": "Keep dither strength", "label_on": "Increase dither strength"}), - "error_diffusion": ("BOOLEAN", {"default": False, "label_off": "Ignore endpoint offset", "label_on": "Apply endpoint offset"}), "normalize_midpeaks": ("BOOLEAN", {"default": False, "label_on": "Anti-spike filter: ON", "label_off": "Anti-spike filter: OFF"}), "peak_width": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}), + "dither_quantization": ("BOOLEAN", {"default": False, "label_off": "Dither quantization OFF", "label_on": "Dither quantization ON"}), + "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_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}), @@ -2279,12 +2279,9 @@ class PrimereRasterix: rasterix_json_path = os.path.join(PRIMERE_ROOT, 'front_end', 'rasterix.json') rasterix_data = utility.json2tuple(rasterix_json_path) or {} - stretched_gaps_spike = [] - scale_spike = [] - rng_gap_spike = [] if auto_normalize: - pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision) + pil_img = img_levels_auto.img_levels_auto(image=pil_img, auto_normalize=auto_normalize, threshold=auto_levels_threshold, normalize_gaps=normalize_gaps, normalize_midpeaks=False, peak_width=peak_width, auto_gamma=auto_gamma, gamma_target=gamma_target, precision=precision) if use_white_balance and (wb_temperature != 6500 or wb_tint != 0): pil_img = img_white_balance.img_white_balance(image=pil_img, temperature=wb_temperature, tint=wb_tint) @@ -2325,7 +2322,7 @@ class PrimereRasterix: pil_img = img_levels_compress.img_levels_compress(image=pil_img, black_offset=black_offset, white_offset=white_offset, skip_if_no_clip=skip_if_no_clip, high_precision=precision) if dither_quantization or error_diffusion or normalize_midpeaks: - pil_img = img_dithering.img_dithering(image=pil_img, normalize_gaps_legacy=normalize_gaps, stretched_gaps_spike=stretched_gaps_spike, scale_spike=scale_spike, rng_gap_spike=rng_gap_spike, 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) + 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) 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) diff --git a/components/images/img_dithering.py b/components/images/img_dithering.py index f2ac0b3..3744121 100644 --- a/components/images/img_dithering.py +++ b/components/images/img_dithering.py @@ -2,6 +2,7 @@ import numpy as np from PIL import Image from numpy.lib.stride_tricks import sliding_window_view + def _adaptive_dither_amplitude(scale: float, adaptive: bool, max_val: float) -> float: """ Return dither amplitude in output-code units (LSB of 8-bit domain). @@ -94,31 +95,8 @@ def _normalize_midpeaks_channel( return np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result) -def _normalize_gaps_legacy( - stretched: np.ndarray, - scale: float, - rng_gap: np.random.Generator, - max_val: float = 255.0, -) -> np.ndarray: - """ - Anti-comb filter — TPDF gap dithering (moved from img_levels_auto). - - Fills quantization gaps from non-integer stretch scale factors. - """ - print('------------ 5 -------------------') - 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)) - - return np.clip(stretched + noise, 0.0, max_val) - - def img_dithering( image: Image.Image, - normalize_gaps_legacy: bool = False, - stretched_gaps_spike: list = [], - scale_spike: list = [], - rng_gap_spike: list = [], dither_quantization: bool = True, adaptive_dither_strength: bool = True, error_diffusion: bool = False, @@ -126,56 +104,45 @@ def img_dithering( peak_width: int = 3, high_precision: bool = False, ) -> Image.Image: + """ + Standalone quantization dither stage for post-processing. + + Args: + image: PIL Image (RGB) + dither_quantization: Apply TPDF dither before rounding. + adaptive_dither_strength: Adapt dither amount to current tonal span. + error_diffusion: Use Floyd-Steinberg quantization path. + high_precision: False = process in 8-bit domain (0..255), + True = process in 16-bit domain (0..65535), + then convert back to 8-bit RGB output. + normalize_midpeaks: Alternative/extra anti-spike smoothing near + histogram gaps before final quantization. + peak_width: 1..10 neighborhood used by normalize_midpeaks. + """ if not (1 <= peak_width <= 10): raise ValueError(f"peak_width must be 1–10, got {peak_width}") - print('------------ 2 -------------------') arr_8f = np.array(image.convert("RGB"), dtype=np.float32) max_val = 65535.0 if high_precision else 255.0 scale_factor = max_val / 255.0 arr = arr_8f * scale_factor if high_precision else arr_8f - # 1) Legacy anti-comb (TPDF gap dither from auto-levels stretch) - # - If lists were provided by img_levels_auto → use exact per-channel scale + RNG - # - Otherwise (auto-levels was off) → fallback to a useful non-specific scale - # estimated from the current image tonal span (exactly as you requested). - if normalize_gaps_legacy: - print('------------ 3 -------------------') - if len(stretched_gaps_spike) > 0 and len(scale_spike) > 0 and len(rng_gap_spike) > 0: - print('------------ 4a -------------------') - # Auto-levels was used → use its exact stretch parameters - for ch in range(3): - scale = scale_spike[ch] - rng_gap = rng_gap_spike[ch] - # Apply to the current image (post-gamma if auto_gamma was enabled). - # This is the cleanest logical placement now that the function lives here. - arr[:, :, ch] = _normalize_gaps_legacy(arr[:, :, ch], scale, rng_gap, max_val) - else: - print('------------ 4b -------------------') - # Auto-levels was OFF → run with useful non-specific parameters - local_scale = _estimate_global_scale(arr, max_val) - for ch in range(3): - rng_gap = np.random.default_rng(ch) # same seeding style as levels_auto - arr[:, :, ch] = _normalize_gaps_legacy(arr[:, :, ch], local_scale, rng_gap, max_val) - - # 2) Mid-peak smoothing if normalize_midpeaks: for ch in range(3): rng = np.random.default_rng(100 + ch) arr[:, :, ch] = _normalize_midpeaks_channel(arr[:, :, ch], peak_width, max_val, rng) - # 3) Quantization path if error_diffusion: quantized = _floyd_steinberg_quantize(arr, max_val) else: quant_input = arr if dither_quantization: - local_scale = _estimate_global_scale(quant_input, max_val) - amp = _adaptive_dither_amplitude(local_scale, adaptive_dither_strength, max_val) + scale = _estimate_global_scale(quant_input, max_val) + amp = _adaptive_dither_amplitude(scale, adaptive_dither_strength, max_val) quant_input = quant_input + _tpdf_noise(quant_input.shape, amp) quantized = np.clip(np.rint(quant_input), 0, max_val) out_8f = quantized * (255.0 / max_val) if high_precision else quantized out_8 = np.clip(np.rint(out_8f), 0, 255).astype(np.uint8) - return Image.fromarray(out_8, mode="RGB") \ No newline at end of file + return Image.fromarray(out_8, mode="RGB") diff --git a/components/images/img_levels_auto.py b/components/images/img_levels_auto.py index d6f7b2e..7d46d02 100644 --- a/components/images/img_levels_auto.py +++ b/components/images/img_levels_auto.py @@ -130,6 +130,73 @@ def levels_edge_spread( return result + +def levels_normalize_midpeaks( + stretched: np.ndarray, + peak_width: int, + 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 + + gap_bins = set(int(b) for b in range(lo, hi) if s_hist[b] == 0) + if not gap_bins: + return result + + 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 + + from numpy.lib.stride_tricks import sliding_window_view + pad = peak_width + padded = np.pad(gap_arr, pad, mode='constant', constant_values=False) + 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]: + continue + qualify_mask |= (s_int == b) + + result = np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result) + return result + + def levels_normalize_gaps( stretched: np.ndarray, scale: float, @@ -157,8 +224,7 @@ def levels_normalize_gaps( """ 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)) + noise = (rng_gap.uniform(-half, half, stretched.shape).astype(np.float32) + rng_gap.uniform(-half, half, stretched.shape).astype(np.float32)) return np.clip(stretched + noise, 0.0, max_val) @@ -208,12 +274,12 @@ def levels_auto_gamma( def img_levels_auto( image: Image.Image, - auto_normalize: bool = False, + auto_normalize: bool = True, threshold: float = 2.0, - # normalize_gaps: bool = False, - # normalize_midpeaks: bool = False, - # peak_width: int = 3, - auto_gamma: bool = False, + normalize_gaps: bool = True, + normalize_midpeaks: bool = False, + peak_width: int = 3, + auto_gamma: bool = True, gamma_target: float = 128.0, precision: bool = False, ) -> Image.Image: @@ -273,9 +339,6 @@ def img_levels_auto( 6. levels_auto_gamma — gamma correction (if auto_gamma) """ img = image.convert("RGB") - stretched_gaps_spike = [] - scale_spike = [] - rng_gap_spike = [] if not auto_normalize: return img @@ -284,8 +347,8 @@ def img_levels_auto( raise ValueError(f"threshold must be 0.0–100.0, got {threshold}") if not (0.0 <= gamma_target <= 255.0): raise ValueError(f"gamma_target must be 0–255, got {gamma_target}") - # if not (1 <= peak_width <= 10): - # raise ValueError(f"peak_width must be 1–10, got {peak_width}") + 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 @@ -302,7 +365,7 @@ def img_levels_auto( for ch in range(3): rng_gap = np.random.default_rng(ch) - # rng_spike = np.random.default_rng(ch + 100) + rng_spike = np.random.default_rng(ch + 100) channel = arr[:, :, ch] @@ -316,15 +379,12 @@ def img_levels_auto( 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) + if normalize_midpeaks: + stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike, max_val) # 5. Gap dithering - ''' if normalize_gaps: + if normalize_gaps: stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val) - stretched_gaps_spike.append(stretched) - scale_spike.append(scale) - rng_gap_spike.append(rng_gap) ''' # 6. Auto gamma if auto_gamma: