diff --git a/Nodes/Dashboard.py b/Nodes/Dashboard.py index fc625bb..2ac1d41 100644 --- a/Nodes/Dashboard.py +++ b/Nodes/Dashboard.py @@ -2230,6 +2230,7 @@ class PrimereRasterix: "models": (["Auto"] + cls.MODELLIST,), "image": ("IMAGE", {"forceInput": True}), + "precision": ("BOOLEAN", {"default": False, "label_off": "8 bit", "label_on": "16 bit"}), "auto_normalize": ("BOOLEAN", {"default": False, "label_off": "No auto levels", "label_on": "Apply auto levels"}), "auto_levels_threshold": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 10.0, "step": 0.1}), @@ -2309,7 +2310,7 @@ class PrimereRasterix: } } - def primere_rasterix(self, concepts, models, image, auto_normalize, auto_levels_threshold, normalize_gaps, normalize_midpeaks, peak_width, auto_gamma, gamma_target, use_white_balance, wb_temperature, wb_tint, use_blur, blur_type, blur_intensity, blur_radius, angle, bilateral_edge_sensitivity, blur_edge_only, edge_threshold, use_smart_lighting, smart_lighting, use_brightness_contrast, brightness, contrast, use_legacy, use_film_rendering, film_rendering, film_rendering_intensity, use_selective_tone, selective_tone_value, selective_tone_zone, selective_tone_separation, selective_tone_strength, use_color_balance, color_balance_cyan_red, color_balance_magenta_green, color_balance_yellow_blue, color_balance_tone, color_balance_preserve_luminosity, color_balance_separation, use_hsl, hsl_hue, hsl_saturation, hsl_lightness, hsl_vibrance, hsl_channel, hsl_channel_width, hsl_skin_protection, use_shade_detailer, shade_level, shade_radius, detail_mode, shade_strength, use_ai_detection_bypasser, adb_freq_strength, adb_variance_strength, adb_unsharp_percent, adb_jpeg_cycles, show_histogram=False, histogram_channel="RGB", histogram_style="gradient", model_concept=None, model_name=None): + def primere_rasterix(self, concepts, models, image, precision, auto_normalize, auto_levels_threshold, normalize_gaps, normalize_midpeaks, peak_width, auto_gamma, gamma_target, use_white_balance, wb_temperature, wb_tint, use_blur, blur_type, blur_intensity, blur_radius, angle, bilateral_edge_sensitivity, blur_edge_only, edge_threshold, use_smart_lighting, smart_lighting, use_brightness_contrast, brightness, contrast, use_legacy, use_film_rendering, film_rendering, film_rendering_intensity, use_selective_tone, selective_tone_value, selective_tone_zone, selective_tone_separation, selective_tone_strength, use_color_balance, color_balance_cyan_red, color_balance_magenta_green, color_balance_yellow_blue, color_balance_tone, color_balance_preserve_luminosity, color_balance_separation, use_hsl, hsl_hue, hsl_saturation, hsl_lightness, hsl_vibrance, hsl_channel, hsl_channel_width, hsl_skin_protection, use_shade_detailer, shade_level, shade_radius, detail_mode, shade_strength, use_ai_detection_bypasser, adb_freq_strength, adb_variance_strength, adb_unsharp_percent, adb_jpeg_cycles, show_histogram=False, histogram_channel="RGB", histogram_style="gradient", model_concept=None, model_name=None): pil_img = utility.tensor_to_image(image) pil_img_input = pil_img.copy() @@ -2317,7 +2318,7 @@ class PrimereRasterix: rasterix_data = utility.json2tuple(rasterix_json_path) or {} if auto_normalize: - 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=normalize_midpeaks, peak_width=peak_width, auto_gamma=auto_gamma, gamma_target=gamma_target) + 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=normalize_midpeaks, 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) diff --git a/components/images/img_levels_auto.py b/components/images/img_levels_auto.py index c70029c..a4e8051 100644 --- a/components/images/img_levels_auto.py +++ b/components/images/img_levels_auto.py @@ -6,9 +6,10 @@ from PIL import Image # Constants # ───────────────────────────────────────────────────────────────────────────── -EDGE_SPREAD = 8.0 # bins to spread clipped edge pixels across -GAMMA_MIN = 0.25 # clamp auto gamma to safe range -GAMMA_MAX = 4.0 +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 # ───────────────────────────────────────────────────────────────────────────── @@ -16,41 +17,44 @@ GAMMA_MAX = 4.0 # ───────────────────────────────────────────────────────────────────────────── def levels_detect_points( - channel: np.ndarray, - threshold: float, + 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–255 + 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 = 255 / (white_point - black_point) + scale = max_val / (white_point - black_point) """ - hist, _ = np.histogram(channel, bins=256, range=(0, 256)) - cumulative = np.cumsum(hist) - total_pixels = int(cumulative[-1]) - abs_cutoff = total_pixels * (threshold / 100.0) + n_bins = int(max_val) + 1 + hist, _ = np.histogram(channel, bins=n_bins, range=(0, max_val + 1)) + cumulative = np.cumsum(hist) + total = int(cumulative[-1]) + cutoff = total * (threshold / 100.0) black_point = 0 - for i in range(256): - if cumulative[i] >= abs_cutoff: + for i in range(n_bins): + if cumulative[i] >= cutoff: black_point = i break - white_point = 255 - for i in range(255, -1, -1): - if (total_pixels - cumulative[i]) >= abs_cutoff: + white_point = int(max_val) + for i in range(n_bins - 1, -1, -1): + if (total - cumulative[i]) >= cutoff: white_point = i break if white_point <= black_point: - white_point = min(black_point + 1, 255) + white_point = min(black_point + 1, int(max_val)) - scale = 255.0 / (white_point - black_point) + scale = max_val / (white_point - black_point) return black_point, white_point, scale @@ -58,21 +62,23 @@ def levels_stretch( channel: np.ndarray, black_point: int, white_point: int, + max_val: float = 255.0, ) -> np.ndarray: """ - Linear stretch of channel values to [0 … 255]. + Linear stretch of channel values to [0 … max_val]. Args: - channel : 2D float32 array, values 0–255 + channel : 2D float32 array black_point : input value that maps to 0 - white_point : input value that maps to 255 + 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, 255] + Stretched float32 array clipped to [0, max_val] """ - scale = 255.0 / (white_point - black_point) + scale = max_val / (white_point - black_point) stretched = (channel - black_point) * scale - return np.clip(stretched, 0.0, 255.0) + return np.clip(stretched, 0.0, max_val) def levels_edge_spread( @@ -80,25 +86,27 @@ def levels_edge_spread( stretched: np.ndarray, black_point: int, white_point: int, + max_val: float = 255.0, ) -> np.ndarray: """ Rank-based edge spread — always applied, not gated by any boolean. - Pixels below black_point all clipped to 0 after stretch. Rather than - piling them into bin 0, they are spread uniformly across [0 … EDGE_SPREAD] - using rank ordering — flat distribution regardless of input clustering. - Same for white-clipped pixels spread to [255-EDGE_SPREAD … 255]. + 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, values 0–255 + 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 """ - result = stretched.copy() + edge_spread = EDGE_SPREAD_RATIO * max_val # ~8 at 8-bit, ~2056 at 16-bit + result = stretched.copy() if black_point > 0: below_mask = channel < black_point @@ -107,17 +115,17 @@ def levels_edge_spread( n = len(flat_idx) rank_order = np.argsort(np.argsort(channel.ravel()[flat_idx])) flat_out = result.ravel().copy() - flat_out[flat_idx] = EDGE_SPREAD * rank_order / max(n - 1, 1) + flat_out[flat_idx] = edge_spread * rank_order / max(n - 1, 1) result = flat_out.reshape(result.shape) - if white_point < 255: + if white_point < int(max_val): above_mask = channel > white_point if above_mask.any(): flat_idx = np.where(above_mask.ravel())[0] n = len(flat_idx) rank_order = np.argsort(np.argsort(channel.ravel()[flat_idx])) flat_out = result.ravel().copy() - flat_out[flat_idx] = (255.0 - EDGE_SPREAD) + EDGE_SPREAD * rank_order / max(n - 1, 1) + flat_out[flat_idx] = (max_val - edge_spread) + edge_spread * rank_order / max(n - 1, 1) result = flat_out.reshape(result.shape) return result @@ -127,80 +135,65 @@ 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 peaks near quantization gaps. + Anti-spike filter — smooths histogram bins near quantization gaps. - After integer stretch with scale > 1, a comb pattern appears: some output - bins receive no pixels (gaps) while adjacent bins receive the displaced - pixels and appear as thin peaks visually. This function redistributes - pixels from peak bins into neighboring gap bins by applying targeted - TPDF dithering only to pixels in bins within peak_width distance of a gap. - - Detection: a bin qualifies as a peak if it has at least one zero bin - within peak_width positions on either side. No ratio threshold — the - user controls sensitivity directly via peak_width. - - Correction: targeted TPDF dithering applied ONLY to pixels in the - qualifying bin. Amplitude = peak_width / 2 pixels. Wider peak_width - both catches more bins AND spreads their pixels further — double effect. + 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, values 0–255, after edge spread - peak_width : 1–10. Distance from a gap within which a bin is - considered a peak and gets smoothed. - 1 = only bins directly adjacent to gaps (surgical) - 3 = bins within 3 of any gap (default, balanced) - 10 = wide smoothing around all gap regions - rng_spike : np.random.Generator, kept separate from gap dithering + 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 toward gap bins + Float32 array with peak bins redistributed """ + n_bins = int(max_val) + 1 result = stretched.copy() - s_int = np.clip(np.round(result).astype(np.int32), 0, 255) - s_hist = np.bincount(s_int.ravel(), minlength=256).astype(np.float64) + 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) - # Build set of gap bins for fast lookup - gap_bins = set(int(b) for b in range(9, 247) if s_hist[b] == 0) + # 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 # no gaps to smooth + return result - amp = peak_width / 2.0 + amp = (peak_width / 2.0) * (max_val / 255.0) # scale amplitude with bit depth half = amp / 2.0 - # Generate ONE noise array for the entire channel — all qualifying bins - # use the same amplitude (peak_width / 2) so a single TPDF noise field - # covers all of them. Each bin's mask selects which pixels receive it. - # This reduces rng calls from 2 × N_bins to 2 total — the critical fix - # for performance on large images with many qualifying bins. + # 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)) - # Build qualifying bin mask vectorized using numpy - # A bin qualifies if any bin within peak_width distance is a gap. - gap_arr = np.zeros(256, dtype=bool) + # Vectorized near-gap detection via sliding window + gap_arr = np.zeros(n_bins, dtype=bool) for g in gap_bins: gap_arr[g] = True - # For each bin b, check if any position in [b-pw, b+pw] is a gap - # Equivalent to convolving gap_arr with a window of width 2*peak_width+1 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) # shape (256, 2*pad+1) - near_gap = windows.any(axis=1) # shape (256,) + 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 combined mask: all pixels in qualifying non-gap bins + # Build mask: all pixels in qualifying non-gap bins qualify_mask = np.zeros(result.shape, dtype=bool) - for b in range(9, 247): + for b in range(lo, hi): if s_hist[b] == 0 or not near_gap[b]: continue qualify_mask |= (s_int == b) - # Apply noise only to qualifying pixels - result = np.where(qualify_mask, np.clip(result + noise, 0.0, 255.0), result) - + result = np.where(qualify_mask, np.clip(result + noise, 0.0, max_val), result) return result @@ -208,72 +201,72 @@ def levels_normalize_gaps( stretched: np.ndarray, scale: float, rng_gap: np.random.Generator, + max_val: float = 255.0, ) -> np.ndarray: """ Anti-comb filter — TPDF gap dithering. - Fills quantization gaps (zero bins) created by integer rounding when - the stretch scale factor is non-integer. Applied to ALL pixels including - the edge-spread region. + 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. - Noise model: TPDF — sum of two uniform distributions. Zero mean, - max change = ±amplitude. - - Amplitude auto-scales: - amplitude = max(1.0, (scale / 1.275) ^ 2.2) - - threshold=2 → scale≈1.28 → amplitude=1.00 (±1.0 px max) - threshold=6 → scale≈1.43 → amplitude=1.28 (±1.3 px max) - threshold=10 → scale≈1.53 → amplitude=1.49 (±1.5 px max) - threshold=20 → scale≈2.02 → amplitude=2.76 (±2.8 px max) + 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, values 0–255 - scale : stretch scale factor (255 / tonal_range) - rng_gap : np.random.Generator for gap dithering noise + 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 quantization gaps filled + Float32 array with gaps filled """ - amplitude = max(1.0, (scale / 1.275) ** 2.2) + 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, 255.0) + return np.clip(stretched + noise, 0.0, max_val) def levels_auto_gamma( stretched: np.ndarray, 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 as: output = (input / 255) ^ (1 / gamma) × 255 - Black (0) and white (255) stay anchored. + 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–255 - gamma_target : target mean brightness 0–255 (128 = neutral 50% grey) + 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()) - if not (0.5 < current_mean < 254.5): + low_guard = 0.5 * (max_val / 255.0) + high_guard = max_val - low_guard + + if not (low_guard < current_mean < high_guard): return stretched target_norm = float(np.clip(gamma_target / 255.0, 0.01, 0.99)) - current_norm = float(np.clip(current_mean / 255.0, 0.01, 0.99)) + current_norm = float(np.clip(current_mean / max_val, 0.01, 0.99)) gamma = np.log(current_norm) / np.log(target_norm) gamma = float(np.clip(gamma, GAMMA_MIN, GAMMA_MAX)) if abs(gamma - 1.0) <= 0.01: return stretched - norm = np.clip(stretched / 255.0, 0.0, 1.0) - return np.clip(np.power(norm, 1.0 / gamma) * 255.0, 0.0, 255.0) + norm = np.clip(stretched / max_val, 0.0, 1.0) + return np.clip(np.power(norm, 1.0 / gamma) * max_val, 0.0, max_val) # ───────────────────────────────────────────────────────────────────────────── @@ -289,6 +282,7 @@ def img_levels_auto( peak_width: int = 3, auto_gamma: bool = True, gamma_target: float = 128.0, + precision: bool = False, ) -> Image.Image: """ Photoshop-style per-channel auto levels normalization. @@ -304,41 +298,42 @@ def img_levels_auto( ~1–2 = subtle, ~5 = moderate, ~10+ = aggressive. normalize_gaps : True = Anti-comb filter. TPDF dithering fills - quantization gaps created by integer rounding. - Amplitude auto-scales with stretch factor. - Independent of normalize_midpeaks. + quantization gaps. Independent of normalize_midpeaks. Default: True. - normalize_midpeaks : True = Anti-spike filter. Smooths histogram bins - that are near quantization gaps, reducing the thin- - peak appearance of the comb pattern. Operates by - targeted dithering on qualifying bins only. - False = function is completely skipped, regardless - of normalize_gaps state. + normalize_midpeaks : True = Anti-spike filter. Smooths bins near gaps. + False = completely skipped regardless of other flags. Default: False. - peak_width : 1 … 10. Controls which bins qualify as peaks and - how far their pixels are spread. - 1 = only bins directly adjacent to a gap - 3 = bins within 3 positions of any gap (default) - 10 = wide smoothing around all gap regions - Larger values catch more bins AND spread pixels - further (double effect). Only used when + 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 to - push mean brightness toward gamma_target. + auto_gamma : True = auto per-channel gamma after stretch. Default: True. - gamma_target : 0 … 255. Target mean brightness. + 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 … 255] + 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) @@ -356,39 +351,56 @@ def img_levels_auto( if not (1 <= peak_width <= 10): raise ValueError(f"peak_width must be 1–10, got {peak_width}") - arr = np.array(img, dtype=np.float32) + # ── 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 + else: + arr = arr_8 + out = np.empty_like(arr) for ch in range(3): - # Independent RNGs per channel — seeded by channel index so that - # spike correction firing on one channel cannot shift the noise - # sequence of gap dithering on any other channel. rng_gap = np.random.default_rng(ch) rng_spike = np.random.default_rng(ch + 100) channel = arr[:, :, ch] # 1. Detect black / white points - black_point, white_point, scale = levels_detect_points(channel, threshold) + black_point, white_point, scale = levels_detect_points( + channel, threshold, max_val) # 2. Stretch - stretched = levels_stretch(channel, black_point, white_point) + 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) + stretched = levels_edge_spread( + channel, stretched, black_point, white_point, max_val) - # 4. Peak smoothing — completely skipped when normalize_midpeaks=False + # 4. Peak smoothing (before gap dithering) if normalize_midpeaks: - stretched = levels_normalize_midpeaks(stretched, peak_width, rng_spike) + stretched = levels_normalize_midpeaks( + stretched, peak_width, rng_spike, max_val) - # 5. Gap dithering — independent of normalize_midpeaks + # 5. Gap dithering if normalize_gaps: - stretched = levels_normalize_gaps(stretched, scale, rng_gap) + stretched = levels_normalize_gaps(stretched, scale, rng_gap, max_val) # 6. Auto gamma if auto_gamma: - stretched = levels_auto_gamma(stretched, gamma_target) + stretched = levels_auto_gamma(stretched, gamma_target, max_val) out[:, :, ch] = stretched - return Image.fromarray(out.astype(np.uint8), mode="RGB") + # ── 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) + + return Image.fromarray(out_8, mode="RGB")