V 2.0.0 - Rasterix - auto level - precision

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-22 11:19:39 +01:00
parent f41f4b5c4f
commit b2e2708437
2 changed files with 155 additions and 142 deletions
+3 -2
View File
@@ -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)
+152 -140
View File
@@ -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")